// 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 }