mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Merge branch 'fix/stock-log' into 'development'
[FIX][BE]: fixing stock log when editing recording See merge request mbugroup/lti-api!503
This commit is contained in:
@@ -0,0 +1,387 @@
|
|||||||
|
// Command: fix-stock-log-drift
|
||||||
|
//
|
||||||
|
// Tujuan:
|
||||||
|
// Sinkronkan `stock_logs.stock` (running ledger) dengan `product_warehouses.qty`
|
||||||
|
// (FIFO truth) ketika keduanya drift.
|
||||||
|
//
|
||||||
|
// Drift biasanya terjadi karena bug di Recording-Edit (sebelum fix) yang
|
||||||
|
// hanya menulis -decrease tanpa +increase saat in-place update. Akibatnya
|
||||||
|
// running ledger di stock_logs tertinggal dari qty riil di product_warehouses.
|
||||||
|
//
|
||||||
|
// Cara kerja:
|
||||||
|
// 1. Ambil product_warehouses.qty (sebagai truth)
|
||||||
|
// 2. Ambil last_stock_log.stock
|
||||||
|
// 3. Cari recording yang berkontribusi pada drift (untuk notes)
|
||||||
|
// 4. Hitung drift = qty - last_stock_log.stock
|
||||||
|
// 5. Jika drift != 0, insert 1 stock_log corrective:
|
||||||
|
// - drift > 0 → increase = drift
|
||||||
|
// - drift < 0 → decrease = |drift|
|
||||||
|
// stock akhir akan sama dengan qty (truth).
|
||||||
|
// Notes otomatis berisi daftar recording IDs yang berkontribusi pada drift.
|
||||||
|
//
|
||||||
|
// Mode:
|
||||||
|
// --apply=false (default) → dry-run, hanya tampilkan rencana
|
||||||
|
// --apply=true → eksekusi insert
|
||||||
|
//
|
||||||
|
// Contoh:
|
||||||
|
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261
|
||||||
|
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply
|
||||||
|
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply \
|
||||||
|
// --actor-id=1 --notes="Koreksi manual drift"
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
qtyEpsilon = 1e-6
|
||||||
|
defaultActorID uint = 1
|
||||||
|
maxSuspectInNotes = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
type driftRow struct {
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
ProductID uint `gorm:"column:product_id"`
|
||||||
|
ProductName string `gorm:"column:product_name"`
|
||||||
|
WarehouseName string `gorm:"column:warehouse_name"`
|
||||||
|
CurrentQty float64 `gorm:"column:current_qty"`
|
||||||
|
LastLogStock float64 `gorm:"column:last_log_stock"`
|
||||||
|
LastLogID uint `gorm:"column:last_log_id"`
|
||||||
|
FifoExpected float64 `gorm:"column:fifo_expected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type suspectRecording struct {
|
||||||
|
RecordingID uint `gorm:"column:recording_id"`
|
||||||
|
FifoUsage float64 `gorm:"column:fifo_usage"`
|
||||||
|
NetLogConsumed float64 `gorm:"column:net_log_consumed"`
|
||||||
|
Phantom float64 `gorm:"column:phantom"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
productWarehouseID uint
|
||||||
|
apply bool
|
||||||
|
actorID uint
|
||||||
|
notes string
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Target product_warehouse_id (required)")
|
||||||
|
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||||
|
flag.UintVar(&actorID, "actor-id", defaultActorID, "User id yang akan dicatat sebagai created_by stock_log corrective")
|
||||||
|
flag.StringVar(¬es, "notes", "", "Custom notes untuk stock_log corrective (opsional, default=auto-generate dari data recording)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
notes = strings.TrimSpace(notes)
|
||||||
|
if err := validateFlags(productWarehouseID, actorID); err != nil {
|
||||||
|
log.Fatalf("invalid flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
|
||||||
|
row, err := loadDriftRow(ctx, db, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load product warehouse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspects, err := loadSuspectRecordings(ctx, db, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load suspect recordings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
drift := row.CurrentQty - row.LastLogStock
|
||||||
|
|
||||||
|
// Print info header
|
||||||
|
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||||
|
fmt.Printf("Target product_warehouse_id: %d\n", productWarehouseID)
|
||||||
|
fmt.Printf("Product: %q\n", row.ProductName)
|
||||||
|
fmt.Printf("Warehouse: %q\n", row.WarehouseName)
|
||||||
|
fmt.Printf("Current qty (product_warehouses): %.3f\n", row.CurrentQty)
|
||||||
|
fmt.Printf("FIFO expected (sum total_qty - total_used): %.3f\n", row.FifoExpected)
|
||||||
|
fmt.Printf("Last stock_log: id=%d stock=%.3f\n", row.LastLogID, row.LastLogStock)
|
||||||
|
fmt.Printf("Drift (qty - last_log_stock): %+.3f\n", drift)
|
||||||
|
|
||||||
|
if !nearlyEqual(row.CurrentQty, row.FifoExpected) {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("⚠️ WARNING: product_warehouses.qty TIDAK match dengan FIFO expected.")
|
||||||
|
fmt.Println(" Disarankan jalankan dulu cmd/reflow-quantity-product-warehouse-from-stock-allocation")
|
||||||
|
fmt.Println(" sebelum fix stock_log drift, agar truth source-nya sudah benar.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print suspect recordings
|
||||||
|
fmt.Println()
|
||||||
|
if len(suspects) > 0 {
|
||||||
|
totalPhantom := 0.0
|
||||||
|
for _, s := range suspects {
|
||||||
|
totalPhantom += s.Phantom
|
||||||
|
}
|
||||||
|
fmt.Printf("Suspect recordings (drift contributors): %d\n", len(suspects))
|
||||||
|
for _, s := range suspects {
|
||||||
|
fmt.Printf(
|
||||||
|
" #%-6d fifo=%-10.3f net_log=%-10.3f phantom=%+.3f\n",
|
||||||
|
s.RecordingID, s.FifoUsage, s.NetLogConsumed, s.Phantom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fmt.Printf("Total suspect phantom: %+.3f\n", totalPhantom)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Suspect recordings: none found (drift origin unknown)")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if nearlyEqual(drift, 0) {
|
||||||
|
fmt.Println("✓ Tidak ada drift. Stock_log sudah sinkron dengan product_warehouses.qty.")
|
||||||
|
fmt.Println("Summary: planned=0 inserted=0 skipped=1 failed=0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build notes if not provided
|
||||||
|
if notes == "" {
|
||||||
|
notes = buildDefaultNotes(row, drift, suspects)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := buildCorrectiveLog(row, drift, actorID, notes)
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN insert stock_log:\n pw=%d increase=%.3f decrease=%.3f stock=%.3f\n notes=%q\n",
|
||||||
|
plan.ProductWarehouseId,
|
||||||
|
plan.Increase,
|
||||||
|
plan.Decrease,
|
||||||
|
plan.Stock,
|
||||||
|
plan.Notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !apply {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=0 (dry-run)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Re-check di dalam transaction agar aman dari race condition
|
||||||
|
current, err := loadDriftRow(ctx, tx, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("re-read product_warehouse_id=%d: %w", productWarehouseID, err)
|
||||||
|
}
|
||||||
|
currentDrift := current.CurrentQty - current.LastLogStock
|
||||||
|
if nearlyEqual(currentDrift, 0) {
|
||||||
|
fmt.Println("Drift hilang sebelum insert (kemungkinan ada operasi paralel). Skip.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fresh := buildCorrectiveLog(current, currentDrift, actorID, notes)
|
||||||
|
if err := tx.Create(&fresh).Error; err != nil {
|
||||||
|
return fmt.Errorf("insert corrective stock_log for pw=%d: %w", productWarehouseID, err)
|
||||||
|
}
|
||||||
|
fmt.Printf(
|
||||||
|
"DONE inserted stock_log id=%d pw=%d increase=%.3f decrease=%.3f stock=%.3f\n",
|
||||||
|
fresh.Id,
|
||||||
|
fresh.ProductWarehouseId,
|
||||||
|
fresh.Increase,
|
||||||
|
fresh.Decrease,
|
||||||
|
fresh.Stock,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=1")
|
||||||
|
log.Printf("error: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Summary: planned=1 inserted=1 skipped=0 failed=0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFlags(productWarehouseID uint, actorID uint) error {
|
||||||
|
if productWarehouseID == 0 {
|
||||||
|
return errors.New("--product-warehouse-id is required (must be > 0)")
|
||||||
|
}
|
||||||
|
if actorID == 0 {
|
||||||
|
return errors.New("--actor-id must be > 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDriftRow(ctx context.Context, db *gorm.DB, productWarehouseID uint) (driftRow, error) {
|
||||||
|
row := driftRow{}
|
||||||
|
|
||||||
|
lastLogSub := db.WithContext(ctx).
|
||||||
|
Table("stock_logs").
|
||||||
|
Select("id, product_warehouse_id, stock").
|
||||||
|
Where("product_warehouse_id = ?", productWarehouseID).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(1)
|
||||||
|
|
||||||
|
fifoSub := db.WithContext(ctx).
|
||||||
|
Table("purchase_items").
|
||||||
|
Select(`
|
||||||
|
product_warehouse_id,
|
||||||
|
COALESCE(SUM(COALESCE(total_qty, 0) - COALESCE(total_used, 0)), 0) AS fifo_expected
|
||||||
|
`).
|
||||||
|
Where("product_warehouse_id = ?", productWarehouseID).
|
||||||
|
Group("product_warehouse_id")
|
||||||
|
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Table("product_warehouses pw").
|
||||||
|
Select(`
|
||||||
|
pw.id AS product_warehouse_id,
|
||||||
|
pw.product_id AS product_id,
|
||||||
|
COALESCE(p.name, '') AS product_name,
|
||||||
|
COALESCE(w.name, '') AS warehouse_name,
|
||||||
|
COALESCE(pw.qty, 0) AS current_qty,
|
||||||
|
COALESCE(last_log.stock, 0) AS last_log_stock,
|
||||||
|
COALESCE(last_log.id, 0) AS last_log_id,
|
||||||
|
COALESCE(fifo.fifo_expected, 0) AS fifo_expected
|
||||||
|
`).
|
||||||
|
Joins("LEFT JOIN products p ON p.id = pw.product_id").
|
||||||
|
Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||||
|
Joins("LEFT JOIN (?) last_log ON last_log.product_warehouse_id = pw.id", lastLogSub).
|
||||||
|
Joins("LEFT JOIN (?) fifo ON fifo.product_warehouse_id = pw.id", fifoSub).
|
||||||
|
Where("pw.id = ?", productWarehouseID).
|
||||||
|
Scan(&row).Error; err != nil {
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.ProductWarehouseID == 0 {
|
||||||
|
return row, fmt.Errorf("product_warehouse_id=%d not found", productWarehouseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSuspectRecordings mencari recording yang net stock_log consumed-nya
|
||||||
|
// melebihi FIFO usage_qty — ini adalah recording yang berkontribusi pada drift
|
||||||
|
// akibat bug Recording-Edit yang hanya menulis -decrease tanpa +increase.
|
||||||
|
func loadSuspectRecordings(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]suspectRecording, error) {
|
||||||
|
rows := make([]suspectRecording, 0)
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Table("recording_stocks rs").
|
||||||
|
Select(`
|
||||||
|
rs.recording_id,
|
||||||
|
COALESCE(rs.usage_qty, 0) AS fifo_usage,
|
||||||
|
(
|
||||||
|
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0)
|
||||||
|
) AS net_log_consumed,
|
||||||
|
(
|
||||||
|
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) -
|
||||||
|
COALESCE(rs.usage_qty, 0)
|
||||||
|
) AS phantom
|
||||||
|
`).
|
||||||
|
Joins(`
|
||||||
|
JOIN stock_logs sl ON sl.loggable_type = ?
|
||||||
|
AND sl.loggable_id = rs.recording_id
|
||||||
|
AND sl.product_warehouse_id = rs.product_warehouse_id
|
||||||
|
`, string(utils.StockLogTypeRecording)).
|
||||||
|
Where("rs.product_warehouse_id = ?", productWarehouseID).
|
||||||
|
Group("rs.recording_id, rs.usage_qty").
|
||||||
|
Having(`
|
||||||
|
ABS(
|
||||||
|
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) -
|
||||||
|
COALESCE(rs.usage_qty, 0)
|
||||||
|
) > ?
|
||||||
|
`, qtyEpsilon).
|
||||||
|
Order("rs.recording_id ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDefaultNotes membuat notes otomatis yang berisi penjelasan drift
|
||||||
|
// beserta daftar recording_id yang berkontribusi + phantom amount masing-masing.
|
||||||
|
func buildDefaultNotes(row driftRow, drift float64, suspects []suspectRecording) string {
|
||||||
|
sign := "+"
|
||||||
|
if drift < 0 {
|
||||||
|
sign = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf(
|
||||||
|
"Koreksi drift stock_log akibat bug Recording-Edit (in-place update menulis -decrease tanpa +increase). PW=%d (%s) drift=%s%.3f.",
|
||||||
|
row.ProductWarehouseID,
|
||||||
|
row.WarehouseName,
|
||||||
|
sign,
|
||||||
|
drift,
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(suspects) == 0 {
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(" Recordings affected:")
|
||||||
|
|
||||||
|
limit := len(suspects)
|
||||||
|
truncated := 0
|
||||||
|
if limit > maxSuspectInNotes {
|
||||||
|
truncated = limit - maxSuspectInNotes
|
||||||
|
limit = maxSuspectInNotes
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
s := suspects[i]
|
||||||
|
phantomSign := "+"
|
||||||
|
if s.Phantom < 0 {
|
||||||
|
phantomSign = ""
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" #%d(%s%.0f)", s.RecordingID, phantomSign, s.Phantom))
|
||||||
|
if i < limit-1 || truncated > 0 {
|
||||||
|
sb.WriteString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if truncated > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" ... (+%d more)", truncated))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(".")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCorrectiveLog(row driftRow, drift float64, actorID uint, notes string) entity.StockLog {
|
||||||
|
corrective := entity.StockLog{
|
||||||
|
ProductWarehouseId: row.ProductWarehouseID,
|
||||||
|
CreatedBy: actorID,
|
||||||
|
LoggableType: string(utils.StockLogTypeAdjustment),
|
||||||
|
LoggableId: 0,
|
||||||
|
Stock: row.CurrentQty,
|
||||||
|
Notes: notes,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if drift > 0 {
|
||||||
|
corrective.Increase = drift
|
||||||
|
corrective.Decrease = 0
|
||||||
|
} else {
|
||||||
|
corrective.Increase = 0
|
||||||
|
corrective.Decrease = -drift
|
||||||
|
}
|
||||||
|
return corrective
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeLabel(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func nearlyEqual(a, b float64) bool {
|
||||||
|
return math.Abs(a-b) <= qtyEpsilon
|
||||||
|
}
|
||||||
@@ -3121,6 +3121,12 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
|||||||
existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock)
|
existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shouldWriteLog := shouldWriteRecordingStockLog(note, actorID)
|
||||||
|
if shouldWriteLog && s.StockLogRepo == nil {
|
||||||
|
return errors.New("stock log repository is not available")
|
||||||
|
}
|
||||||
|
resetLogState := newRecordingStockLogState()
|
||||||
|
|
||||||
stocksToApply := make([]entity.RecordingStock, 0, len(incoming))
|
stocksToApply := make([]entity.RecordingStock, 0, len(incoming))
|
||||||
for _, item := range incoming {
|
for _, item := range incoming {
|
||||||
list := existingByWarehouse[item.ProductWarehouseId]
|
list := existingByWarehouse[item.ProductWarehouseId]
|
||||||
@@ -3128,6 +3134,25 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
|||||||
if len(list) > 0 {
|
if len(list) > 0 {
|
||||||
stock = list[0]
|
stock = list[0]
|
||||||
existingByWarehouse[item.ProductWarehouseId] = list[1:]
|
existingByWarehouse[item.ProductWarehouseId] = list[1:]
|
||||||
|
|
||||||
|
// Write reset (increase) stock_log for the OLD consumption BEFORE overwriting UsageQty.
|
||||||
|
// FIFO internally does Rollback+Reallocate inside reflowApplyRecordingStocks, but the
|
||||||
|
// corresponding +increase stock_log for the rollback step was previously missing, causing
|
||||||
|
// stock_log.stock to drift below the true FIFO qty on every in-place edit.
|
||||||
|
rollbackQty := recordingStockRollbackQty(stock)
|
||||||
|
if rollbackQty > 1e-6 && shouldWriteLog {
|
||||||
|
resetLog := &entity.StockLog{
|
||||||
|
ProductWarehouseId: stock.ProductWarehouseId,
|
||||||
|
CreatedBy: actorID,
|
||||||
|
Increase: rollbackQty,
|
||||||
|
LoggableType: string(utils.StockLogTypeRecording),
|
||||||
|
LoggableId: stock.RecordingId,
|
||||||
|
Notes: note,
|
||||||
|
}
|
||||||
|
if err := s.appendRecordingStockLog(ctx, tx, resetLogState, resetLog); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
zero := 0.0
|
zero := 0.0
|
||||||
stock = entity.RecordingStock{
|
stock = entity.RecordingStock{
|
||||||
|
|||||||
Reference in New Issue
Block a user