diff --git a/cmd/fix-stock-log-drift/main.go b/cmd/fix-stock-log-drift/main.go new file mode 100644 index 00000000..fc06de3c --- /dev/null +++ b/cmd/fix-stock-log-drift/main.go @@ -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 +} diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5264e32a..3f0e7946 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -3121,6 +3121,12 @@ func (s *recordingService) reflowSyncRecordingStocks( 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)) for _, item := range incoming { list := existingByWarehouse[item.ProductWarehouseId] @@ -3128,6 +3134,25 @@ func (s *recordingService) reflowSyncRecordingStocks( if len(list) > 0 { stock = list[0] 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 { zero := 0.0 stock = entity.RecordingStock{