diff --git a/cmd/migrate-legacy-egg-adjustment-stock-to-farm/main.go b/cmd/migrate-legacy-egg-adjustment-stock-to-farm/main.go new file mode 100644 index 00000000..e8819f0e --- /dev/null +++ b/cmd/migrate-legacy-egg-adjustment-stock-to-farm/main.go @@ -0,0 +1,1085 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/go-playground/validator/v10" + "github.com/sirupsen/logrus" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" + "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" + pwRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + transferRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" + transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + pfkRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +const ( + adjustmentCutoverReasonPrefix = "EGG_FARM_ADJUSTMENT_CUTOVER" + adjustmentOutputModeTable = "table" + adjustmentOutputModeJSON = "json" +) + +type adjustmentCommandOptions struct { + Apply bool + DryRun bool + RollbackRunID string + LocationID uint + LocationName string + CutoverDate time.Time + CutoverDateRaw string + Output string + ActorID uint + RunID string +} + +type adjustmentLocationTiming struct { + LocationID uint + LocationName string + FirstKandangDate *time.Time + LastKandangDate *time.Time + FirstFarmDate *time.Time + LastFarmDate *time.Time + Status string +} + +type adjustmentLegacyEggRow struct { + LocationID uint + LocationName string + SourceWarehouseID uint + SourceWarehouseName string + FarmWarehouseID *uint + FarmWarehouseName *string + ProductWarehouseID uint + ProductID uint + ProductName string + RemainingQty float64 + CurrentPWQty float64 + AdjustmentIDs []uint +} + +type adjustmentMigrationReportRow struct { + RunID string `json:"run_id"` + LocationID uint `json:"location_id"` + LocationName string `json:"location_name"` + SourceWarehouseID uint `json:"source_warehouse_id"` + SourceWarehouseName string `json:"source_warehouse_name"` + FarmWarehouseID *uint `json:"farm_warehouse_id,omitempty"` + FarmWarehouseName *string `json:"farm_warehouse_name,omitempty"` + ProductWarehouseID uint `json:"product_warehouse_id"` + ProductID uint `json:"product_id"` + ProductName string `json:"product_name"` + RemainingQty float64 `json:"remaining_qty"` + CurrentPWQty float64 `json:"current_pw_qty"` + VerifiedQty float64 `json:"verified_qty"` + LocationStatus string `json:"location_status"` + AdjustmentIDs []uint `json:"adjustment_ids,omitempty"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + TransferID *uint64 `json:"transfer_id,omitempty"` + MovementNumber *string `json:"movement_number,omitempty"` +} + +type adjustmentApplySummary struct { + RowsPlanned int `json:"rows_planned"` + RowsApplied int `json:"rows_applied"` + RowsSkipped int `json:"rows_skipped"` + RowsFailed int `json:"rows_failed"` + GroupsPlanned int `json:"groups_planned"` + GroupsApplied int `json:"groups_applied"` +} + +type adjustmentRollbackDetailRow struct { + RunID string `json:"run_id"` + TransferID uint64 `json:"transfer_id"` + MovementNumber string `json:"movement_number"` + LocationName string `json:"location_name"` + SourceWarehouseName string `json:"source_warehouse_name"` + FarmWarehouseName string `json:"farm_warehouse_name"` + ProductName string `json:"product_name"` + Qty float64 `json:"qty"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` +} + +type adjustmentTransferGroup struct { + LocationID uint + LocationName string + SourceWarehouseID uint + SourceWarehouseName string + FarmWarehouseID uint + FarmWarehouseName string + Rows []*adjustmentMigrationReportRow +} + +type adjustmentCandidateValidation struct { + Status string + Reason string + VerifiedQty float64 +} + +type adjustmentCandidateValidator interface { + ValidateCandidate(ctx context.Context, row adjustmentLegacyEggRow) (adjustmentCandidateValidation, error) +} + +type liveAdjustmentCandidateValidator struct { + db *gorm.DB + fifoSvc commonSvc.FifoStockV2Service +} + +type adjustmentSystemTransferExecutor interface { + CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) + DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error +} + +func main() { + opts, err := parseAdjustmentFlags() + if err != nil { + log.Fatalf("invalid flags: %v", err) + } + + db := database.Connect(config.DBHost, config.DBName) + ctx := context.Background() + validator := &liveAdjustmentCandidateValidator{ + db: db, + fifoSvc: commonSvc.NewFifoStockV2Service(db, logrus.StandardLogger()), + } + + if strings.TrimSpace(opts.RollbackRunID) != "" { + rows, err := loadAdjustmentRollbackDetails(ctx, db, opts.RollbackRunID) + if err != nil { + log.Fatalf("failed to load rollback details: %v", err) + } + if !opts.Apply { + for i := range rows { + rows[i].Status = "eligible" + } + renderAdjustmentRollbackReport(opts.Output, rows) + return + } + if err := executeAdjustmentRollback(ctx, newAdjustmentSystemTransferService(db), rows, opts.ActorID); err != nil { + log.Fatalf("rollback failed: %v", err) + } + renderAdjustmentRollbackReport(opts.Output, rows) + return + } + + timings, err := loadAdjustmentLocationTimings(ctx, db, opts) + if err != nil { + log.Fatalf("failed to load location timings: %v", err) + } + candidates, err := loadLegacyEggAdjustmentRows(ctx, db, opts) + if err != nil { + log.Fatalf("failed to load legacy adjustment egg rows: %v", err) + } + + reportRows, groups := buildAdjustmentMigrationPlan(ctx, opts, timings, candidates, validator) + if !opts.Apply { + renderAdjustmentMigrationReport(opts.Output, reportRows, summarizeAdjustmentApply(reportRows, groups, 0)) + return + } + + summary, err := executeAdjustmentApply(ctx, newAdjustmentSystemTransferService(db), validator, opts, groups) + if err != nil { + log.Fatalf("apply failed: %v", err) + } + finalRows := flattenAdjustmentGroups(groups, reportRows) + summary = summarizeAdjustmentApply(finalRows, groups, summary.GroupsApplied) + renderAdjustmentMigrationReport(opts.Output, finalRows, summary) +} + +func parseAdjustmentFlags() (*adjustmentCommandOptions, error) { + var opts adjustmentCommandOptions + flag.BoolVar(&opts.Apply, "apply", false, "Apply migration. If false, run as dry-run") + flag.BoolVar(&opts.DryRun, "dry-run", true, "Run as dry-run") + flag.StringVar(&opts.RollbackRunID, "rollback-run-id", "", "Rollback all transfers created by the provided run id") + flag.UintVar(&opts.LocationID, "location-id", 0, "Filter by location id") + flag.StringVar(&opts.LocationName, "location-name", "", "Filter by exact location name") + flag.StringVar(&opts.CutoverDateRaw, "cutover-date", "", "Cutover date in YYYY-MM-DD format") + flag.StringVar(&opts.Output, "output", adjustmentOutputModeTable, "Output format: table or json") + flag.UintVar(&opts.ActorID, "actor-id", 1, "Actor id used for created/deleted transfers") + flag.Parse() + + opts.LocationName = strings.TrimSpace(opts.LocationName) + opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID) + opts.Output = strings.ToLower(strings.TrimSpace(opts.Output)) + if opts.Output == "" { + opts.Output = adjustmentOutputModeTable + } + if opts.Output != adjustmentOutputModeTable && opts.Output != adjustmentOutputModeJSON { + return nil, fmt.Errorf("unsupported --output=%s", opts.Output) + } + if opts.Apply { + opts.DryRun = false + } + if opts.LocationID > 0 && opts.LocationName != "" { + return nil, errors.New("use either --location-id or --location-name, not both") + } + if opts.RollbackRunID != "" { + if opts.LocationID > 0 || opts.LocationName != "" { + return nil, errors.New("location filters are not supported with --rollback-run-id") + } + if opts.CutoverDateRaw != "" { + return nil, errors.New("--cutover-date is not used with --rollback-run-id") + } + } else if opts.Apply { + if opts.LocationID == 0 && opts.LocationName == "" { + return nil, errors.New("apply mode requires --location-id or --location-name for safety") + } + if strings.TrimSpace(opts.CutoverDateRaw) == "" { + return nil, errors.New("--cutover-date is required in apply mode") + } + } + + if strings.TrimSpace(opts.CutoverDateRaw) == "" { + opts.CutoverDate = normalizeAdjustmentDateOnly(time.Now().In(time.FixedZone("Asia/Jakarta", 7*3600))) + } else { + t, err := time.Parse("2006-01-02", opts.CutoverDateRaw) + if err != nil { + return nil, fmt.Errorf("invalid --cutover-date: %w", err) + } + opts.CutoverDate = normalizeAdjustmentDateOnly(t) + } + + opts.RunID = buildAdjustmentRunID() + return &opts, nil +} + +func newAdjustmentSystemTransferService(db *gorm.DB) adjustmentSystemTransferExecutor { + validate := validator.New() + stockTransferRepo := transferRepo.NewStockTransferRepository(db) + stockTransferDetailRepo := transferRepo.NewStockTransferDetailRepository(db) + stockTransferDeliveryRepo := transferRepo.NewStockTransferDeliveryRepository(db) + stockTransferDeliveryItemRepo := transferRepo.NewStockTransferDeliveryItemRepository(db) + stockLogsRepo := stockLogRepo.NewStockLogRepository(db) + productWarehouseRepo := pwRepo.NewProductWarehouseRepository(db) + warehouseRepository := warehouseRepo.NewWarehouseRepository(db) + projectFlockKandangRepo := pfkRepo.NewProjectFlockKandangRepository(db) + projectFlockPopulationRepo := pfkRepo.NewProjectFlockPopulationRepository(db) + fifoSvc := commonSvc.NewFifoStockV2Service(db, logrus.StandardLogger()) + + return transferSvc.NewTransferService( + validate, + stockTransferRepo, + stockTransferDetailRepo, + stockTransferDeliveryRepo, + stockTransferDeliveryItemRepo, + stockLogsRepo, + productWarehouseRepo, + nil, + warehouseRepository, + projectFlockKandangRepo, + projectFlockPopulationRepo, + nil, + fifoSvc, + nil, + ) +} + +func loadAdjustmentLocationTimings(ctx context.Context, db *gorm.DB, opts *adjustmentCommandOptions) (map[uint]adjustmentLocationTiming, error) { + type row struct { + LocationID uint `gorm:"column:location_id"` + LocationName string `gorm:"column:location_name"` + FirstKandangDate *time.Time `gorm:"column:first_kandang_date"` + LastKandangDate *time.Time `gorm:"column:last_kandang_date"` + FirstFarmDate *time.Time `gorm:"column:first_farm_date"` + LastFarmDate *time.Time `gorm:"column:last_farm_date"` + } + + query := db.WithContext(ctx). + Table("recording_eggs re"). + Select(` + pf.location_id AS location_id, + l.name AS location_name, + MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date, + MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date, + MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date, + MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date + `). + Joins("JOIN recordings r ON r.id = re.recording_id"). + Joins("JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)"). + Joins("JOIN project_flocks pf ON pf.id = pk.project_flock_id"). + Joins("JOIN locations l ON l.id = pf.location_id"). + Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Group("pf.location_id, l.name") + query = applyAdjustmentTimingLocationFilter(query, opts) + + var rows []row + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]adjustmentLocationTiming, len(rows)) + for _, row := range rows { + status := "KANDANG_ONLY" + if row.FirstFarmDate != nil { + status = "OVERLAP" + if row.LastKandangDate == nil || row.FirstFarmDate.After(normalizeAdjustmentDateOnly(*row.LastKandangDate)) { + status = "CLEAN_CUTOVER" + } + } + result[row.LocationID] = adjustmentLocationTiming{ + LocationID: row.LocationID, + LocationName: row.LocationName, + FirstKandangDate: normalizeAdjustmentDatePtr(row.FirstKandangDate), + LastKandangDate: normalizeAdjustmentDatePtr(row.LastKandangDate), + FirstFarmDate: normalizeAdjustmentDatePtr(row.FirstFarmDate), + LastFarmDate: normalizeAdjustmentDatePtr(row.LastFarmDate), + Status: status, + } + } + + return result, nil +} + +func loadLegacyEggAdjustmentRows(ctx context.Context, db *gorm.DB, opts *adjustmentCommandOptions) ([]adjustmentLegacyEggRow, error) { + type row struct { + LocationID uint `gorm:"column:location_id"` + LocationName string `gorm:"column:location_name"` + SourceWarehouseID uint `gorm:"column:source_warehouse_id"` + SourceWarehouseName string `gorm:"column:source_warehouse_name"` + FarmWarehouseID *uint `gorm:"column:farm_warehouse_id"` + FarmWarehouseName *string `gorm:"column:farm_warehouse_name"` + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + RemainingQty float64 `gorm:"column:remaining_qty"` + CurrentPWQty float64 `gorm:"column:current_pw_qty"` + AdjustmentIDs string `gorm:"column:adjustment_ids"` + } + + firstFarmSub := db.WithContext(ctx). + Table("warehouses fw"). + Select("fw.location_id AS location_id, MIN(fw.id) AS farm_warehouse_id"). + Where("fw.deleted_at IS NULL"). + Where("fw.type = ?", "LOKASI"). + Group("fw.location_id") + + query := db.WithContext(ctx). + Table("adjustment_stocks ast"). + Select(` + kw.location_id AS location_id, + l.name AS location_name, + kw.id AS source_warehouse_id, + kw.name AS source_warehouse_name, + fw.id AS farm_warehouse_id, + fw.name AS farm_warehouse_name, + pw.id AS product_warehouse_id, + pw.product_id AS product_id, + p.name AS product_name, + COALESCE(pw.qty, 0) AS current_pw_qty, + SUM(GREATEST(ast.total_qty - ast.total_used, 0)) AS remaining_qty, + STRING_AGG(ast.id::text, ',' ORDER BY ast.id) AS adjustment_ids + `). + Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id"). + Joins("JOIN warehouses kw ON kw.id = pw.warehouse_id AND kw.deleted_at IS NULL"). + Joins("JOIN locations l ON l.id = kw.location_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN product_categories pc ON pc.id = p.product_category_id"). + Joins("LEFT JOIN (?) ff ON ff.location_id = kw.location_id", firstFarmSub). + Joins("LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id"). + Where("kw.type = ?", "KANDANG"). + Where("ast.total_qty > ast.total_used"). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_type = ? + AND f.flagable_id = p.id + AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%') + ) + OR ( + NOT EXISTS ( + SELECT 1 + FROM flags f_any + WHERE f_any.flagable_type = ? + AND f_any.flagable_id = p.id + ) + AND UPPER(COALESCE(pc.code, '')) = 'EGG' + ) + `, entity.FlagableTypeProduct, entity.FlagableTypeProduct). + Group(` + kw.location_id, + l.name, + kw.id, + kw.name, + fw.id, + fw.name, + pw.id, + pw.product_id, + p.name, + pw.qty + `). + Order("l.name ASC, kw.name ASC, p.name ASC") + query = applyAdjustmentLegacyLocationFilter(query, opts) + + var rows []row + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + result := make([]adjustmentLegacyEggRow, 0, len(rows)) + for _, row := range rows { + result = append(result, adjustmentLegacyEggRow{ + LocationID: row.LocationID, + LocationName: row.LocationName, + SourceWarehouseID: row.SourceWarehouseID, + SourceWarehouseName: row.SourceWarehouseName, + FarmWarehouseID: row.FarmWarehouseID, + FarmWarehouseName: row.FarmWarehouseName, + ProductWarehouseID: row.ProductWarehouseID, + ProductID: row.ProductID, + ProductName: row.ProductName, + RemainingQty: row.RemainingQty, + CurrentPWQty: row.CurrentPWQty, + AdjustmentIDs: parseAdjustmentIDs(row.AdjustmentIDs), + }) + } + + return result, nil +} + +func (v *liveAdjustmentCandidateValidator) ValidateCandidate(ctx context.Context, row adjustmentLegacyEggRow) (adjustmentCandidateValidation, error) { + if row.RemainingQty <= 0 { + return adjustmentCandidateValidation{Status: "skipped", Reason: "non_positive_remaining_qty"}, nil + } + if row.ProductWarehouseID == 0 || row.ProductID == 0 { + return adjustmentCandidateValidation{Status: "failed", Reason: "invalid_candidate"}, nil + } + + flagGroupCode, err := resolveAdjustmentEggFlagGroup(ctx, v.db, row.ProductID) + if err != nil { + return adjustmentCandidateValidation{}, err + } + + gatherRows, err := v.fifoSvc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: fifoStockV2.LaneStockable, + AllocationPurpose: entity.StockAllocationPurposeConsume, + ProductWarehouseID: row.ProductWarehouseID, + Limit: 1000, + }) + if err != nil { + return adjustmentCandidateValidation{}, err + } + + return validateAdjustmentGatherAgainstAllowedIDs(row.RemainingQty, row.AdjustmentIDs, gatherRows), nil +} + +func validateAdjustmentGatherAgainstAllowedIDs( + needQty float64, + allowedIDs []uint, + rows []commonSvc.FifoStockV2GatherRow, +) adjustmentCandidateValidation { + if needQty <= 0 { + return adjustmentCandidateValidation{Status: "skipped", Reason: "non_positive_remaining_qty"} + } + + allowed := make(map[uint]struct{}, len(allowedIDs)) + for _, id := range allowedIDs { + if id > 0 { + allowed[id] = struct{}{} + } + } + if len(allowed) == 0 { + return adjustmentCandidateValidation{Status: "failed", Reason: "missing_adjustment_ids"} + } + + const epsilon = 1e-6 + verifiedQty := 0.0 + for _, row := range rows { + if row.AvailableQuantity <= epsilon { + continue + } + + takeQty := row.AvailableQuantity + if remaining := needQty - verifiedQty; takeQty > remaining { + takeQty = remaining + } + if takeQty <= epsilon { + continue + } + + if row.SourceTable != "adjustment_stocks" { + return adjustmentCandidateValidation{ + Status: "skipped", + Reason: fmt.Sprintf("mixed_fifo_source_%s", strings.ToLower(strings.TrimSpace(row.SourceTable))), + VerifiedQty: verifiedQty, + } + } + if _, ok := allowed[row.SourceID]; !ok { + return adjustmentCandidateValidation{ + Status: "skipped", + Reason: "mixed_adjustment_source", + VerifiedQty: verifiedQty, + } + } + + verifiedQty += takeQty + if verifiedQty >= needQty-epsilon { + return adjustmentCandidateValidation{ + Status: "eligible", + VerifiedQty: verifiedQty, + } + } + } + + return adjustmentCandidateValidation{ + Status: "skipped", + Reason: "insufficient_fifo_available", + VerifiedQty: verifiedQty, + } +} + +func resolveAdjustmentEggFlagGroup(ctx context.Context, db *gorm.DB, productID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + var selected row + err := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", "USABLE"). + Where("rr.function_code = ?", "STOCK_TRANSFER_OUT"). + Where("rr.source_table = ?", "stock_transfer_details"). + Where(` + EXISTS ( + SELECT 1 + FROM products p + LEFT JOIN product_categories pc ON pc.id = p.product_category_id + WHERE p.id = ? + AND ( + EXISTS ( + SELECT 1 + FROM flags f + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE f.flagable_type = ? + AND f.flagable_id = p.id + AND fm.flag_group_code = rr.flag_group_code + ) + OR ( + NOT EXISTS ( + SELECT 1 + FROM flags f_any + WHERE f_any.flagable_type = ? + AND f_any.flagable_id = p.id + ) + AND rr.flag_group_code = ? + AND UPPER(COALESCE(pc.code, '')) = 'EGG' + ) + ) + ) + `, productID, entity.FlagableTypeProduct, entity.FlagableTypeProduct, utils.LegacyFlagGroupCodeByProductCategoryCode("EGG")). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} + +func buildAdjustmentMigrationPlan( + ctx context.Context, + opts *adjustmentCommandOptions, + timings map[uint]adjustmentLocationTiming, + rows []adjustmentLegacyEggRow, + validator adjustmentCandidateValidator, +) ([]adjustmentMigrationReportRow, []adjustmentTransferGroup) { + reportRows := make([]adjustmentMigrationReportRow, 0, len(rows)) + groupMap := make(map[string]*adjustmentTransferGroup) + + for _, row := range rows { + locationStatus := "UNKNOWN" + if timing, ok := timings[row.LocationID]; ok { + locationStatus = timing.Status + } + + report := adjustmentMigrationReportRow{ + RunID: opts.RunID, + LocationID: row.LocationID, + LocationName: row.LocationName, + SourceWarehouseID: row.SourceWarehouseID, + SourceWarehouseName: row.SourceWarehouseName, + FarmWarehouseID: row.FarmWarehouseID, + FarmWarehouseName: row.FarmWarehouseName, + ProductWarehouseID: row.ProductWarehouseID, + ProductID: row.ProductID, + ProductName: row.ProductName, + RemainingQty: row.RemainingQty, + CurrentPWQty: row.CurrentPWQty, + LocationStatus: locationStatus, + AdjustmentIDs: row.AdjustmentIDs, + Status: "eligible", + } + + switch { + case row.FarmWarehouseID == nil || row.FarmWarehouseName == nil: + report.Status = "skipped" + report.Reason = "missing_farm_warehouse" + case row.RemainingQty <= 0: + report.Status = "skipped" + report.Reason = "non_positive_remaining_qty" + default: + validation, err := validator.ValidateCandidate(ctx, row) + if err != nil { + report.Status = "failed" + report.Reason = err.Error() + } else { + report.Status = validation.Status + report.Reason = validation.Reason + report.VerifiedQty = validation.VerifiedQty + } + } + + reportRows = append(reportRows, report) + if report.Status != "eligible" { + continue + } + + groupKey := fmt.Sprintf("%d:%d", row.SourceWarehouseID, *row.FarmWarehouseID) + group := groupMap[groupKey] + if group == nil { + group = &adjustmentTransferGroup{ + LocationID: row.LocationID, + LocationName: row.LocationName, + SourceWarehouseID: row.SourceWarehouseID, + SourceWarehouseName: row.SourceWarehouseName, + FarmWarehouseID: *row.FarmWarehouseID, + FarmWarehouseName: derefAdjustmentString(row.FarmWarehouseName), + } + groupMap[groupKey] = group + } + group.Rows = append(group.Rows, &reportRows[len(reportRows)-1]) + } + + groups := make([]adjustmentTransferGroup, 0, len(groupMap)) + for _, group := range groupMap { + sort.Slice(group.Rows, func(i, j int) bool { + return group.Rows[i].ProductName < group.Rows[j].ProductName + }) + groups = append(groups, *group) + } + sort.Slice(groups, func(i, j int) bool { + if groups[i].LocationName == groups[j].LocationName { + return groups[i].SourceWarehouseName < groups[j].SourceWarehouseName + } + return groups[i].LocationName < groups[j].LocationName + }) + + return reportRows, groups +} + +func executeAdjustmentApply( + ctx context.Context, + svc adjustmentSystemTransferExecutor, + validator adjustmentCandidateValidator, + opts *adjustmentCommandOptions, + groups []adjustmentTransferGroup, +) (adjustmentApplySummary, error) { + summary := adjustmentApplySummary{GroupsPlanned: len(groups)} + + for _, group := range groups { + eligibleRows := make([]*adjustmentMigrationReportRow, 0, len(group.Rows)) + for _, row := range group.Rows { + validation, err := validator.ValidateCandidate(ctx, adjustmentLegacyEggRow{ + LocationID: row.LocationID, + LocationName: row.LocationName, + SourceWarehouseID: row.SourceWarehouseID, + SourceWarehouseName: row.SourceWarehouseName, + FarmWarehouseID: row.FarmWarehouseID, + FarmWarehouseName: row.FarmWarehouseName, + ProductWarehouseID: row.ProductWarehouseID, + ProductID: row.ProductID, + ProductName: row.ProductName, + RemainingQty: row.RemainingQty, + CurrentPWQty: row.CurrentPWQty, + AdjustmentIDs: row.AdjustmentIDs, + }) + if err != nil { + row.Status = "failed" + row.Reason = err.Error() + summary.RowsFailed++ + continue + } + if validation.Status != "eligible" { + row.Status = "failed" + row.Reason = validation.Reason + row.VerifiedQty = validation.VerifiedQty + summary.RowsFailed++ + continue + } + row.VerifiedQty = validation.VerifiedQty + eligibleRows = append(eligibleRows, row) + } + + if len(eligibleRows) == 0 { + continue + } + + products := make([]transferSvc.SystemTransferProduct, 0, len(eligibleRows)) + for _, row := range eligibleRows { + products = append(products, transferSvc.SystemTransferProduct{ + ProductID: row.ProductID, + ProductQty: row.RemainingQty, + }) + } + + reason := buildAdjustmentCutoverReason(opts.RunID, group.LocationName, opts.CutoverDate) + transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{ + TransferReason: reason, + TransferDate: opts.CutoverDate, + SourceWarehouseID: group.SourceWarehouseID, + DestinationWarehouseID: group.FarmWarehouseID, + Products: products, + ActorID: opts.ActorID, + StockLogNotes: reason, + }) + if err != nil { + for _, row := range eligibleRows { + row.Status = "failed" + row.Reason = err.Error() + summary.RowsFailed++ + } + continue + } + + summary.GroupsApplied++ + for _, row := range eligibleRows { + row.Status = "applied" + row.TransferID = &transfer.Id + row.MovementNumber = &transfer.MovementNumber + summary.RowsApplied++ + } + } + + return summary, nil +} + +func executeAdjustmentRollback( + ctx context.Context, + svc adjustmentSystemTransferExecutor, + rows []adjustmentRollbackDetailRow, + actorID uint, +) error { + if actorID == 0 { + return fmt.Errorf("actor id is required for rollback") + } + + byTransfer := make(map[uint64][]int) + for idx, row := range rows { + byTransfer[row.TransferID] = append(byTransfer[row.TransferID], idx) + } + + transferIDs := make([]uint64, 0, len(byTransfer)) + for transferID := range byTransfer { + transferIDs = append(transferIDs, transferID) + } + sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] }) + + var firstErr error + for _, transferID := range transferIDs { + err := svc.DeleteSystemTransfer(ctx, uint(transferID), actorID) + for _, idx := range byTransfer[transferID] { + if err != nil { + rows[idx].Status = "failed" + rows[idx].Reason = err.Error() + } else { + rows[idx].Status = "rolled_back" + } + } + if err != nil && firstErr == nil { + firstErr = err + } + } + + return firstErr +} + +func loadAdjustmentRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]adjustmentRollbackDetailRow, error) { + type row struct { + TransferID uint64 `gorm:"column:transfer_id"` + MovementNumber string `gorm:"column:movement_number"` + LocationName string `gorm:"column:location_name"` + SourceWarehouseName string `gorm:"column:source_warehouse_name"` + FarmWarehouseName string `gorm:"column:farm_warehouse_name"` + ProductName string `gorm:"column:product_name"` + Qty float64 `gorm:"column:qty"` + } + + needle := buildAdjustmentRunReasonMatcher(runID) + var dbRows []row + err := db.WithContext(ctx). + Table("stock_transfers st"). + Select(` + st.id AS transfer_id, + st.movement_number AS movement_number, + COALESCE(loc.name, '') AS location_name, + ws.name AS source_warehouse_name, + wd.name AS farm_warehouse_name, + p.name AS product_name, + COALESCE(std.total_qty, std.usage_qty, 0) AS qty + `). + Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id"). + Joins("JOIN warehouses wd ON wd.id = st.to_warehouse_id"). + Joins("LEFT JOIN locations loc ON loc.id = COALESCE(ws.location_id, wd.location_id)"). + Joins("JOIN stock_transfer_details std ON std.stock_transfer_id = st.id AND std.deleted_at IS NULL"). + Joins("JOIN products p ON p.id = std.product_id"). + Where("st.deleted_at IS NULL"). + Where("st.reason LIKE ?", needle). + Order("st.id DESC, std.id ASC"). + Scan(&dbRows).Error + if err != nil { + return nil, err + } + + rows := make([]adjustmentRollbackDetailRow, 0, len(dbRows)) + for _, row := range dbRows { + rows = append(rows, adjustmentRollbackDetailRow{ + RunID: runID, + TransferID: row.TransferID, + MovementNumber: row.MovementNumber, + LocationName: row.LocationName, + SourceWarehouseName: row.SourceWarehouseName, + FarmWarehouseName: row.FarmWarehouseName, + ProductName: row.ProductName, + Qty: row.Qty, + }) + } + + return rows, nil +} + +func applyAdjustmentTimingLocationFilter(db *gorm.DB, opts *adjustmentCommandOptions) *gorm.DB { + if opts == nil { + return db + } + switch { + case opts.LocationID > 0: + return db.Where("pf.location_id = ?", opts.LocationID) + case opts.LocationName != "": + return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName) + default: + return db + } +} + +func applyAdjustmentLegacyLocationFilter(db *gorm.DB, opts *adjustmentCommandOptions) *gorm.DB { + if opts == nil { + return db + } + switch { + case opts.LocationID > 0: + return db.Where("kw.location_id = ?", opts.LocationID) + case opts.LocationName != "": + return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName) + default: + return db + } +} + +func buildAdjustmentCutoverReason(runID, locationName string, cutoverDate time.Time) string { + locationName = strings.ReplaceAll(strings.TrimSpace(locationName), "|", "/") + return fmt.Sprintf("%s|run_id=%s|location=%s|cutover_date=%s", adjustmentCutoverReasonPrefix, runID, locationName, cutoverDate.Format("2006-01-02")) +} + +func buildAdjustmentRunReasonMatcher(runID string) string { + return fmt.Sprintf("%s|run_id=%s|%%", adjustmentCutoverReasonPrefix, strings.TrimSpace(runID)) +} + +func buildAdjustmentRunID() string { + return fmt.Sprintf("egg-adjustment-cutover-%s", time.Now().UTC().Format("20060102T150405.000000000Z")) +} + +func normalizeAdjustmentDateOnly(value time.Time) time.Time { + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC) +} + +func normalizeAdjustmentDatePtr(value *time.Time) *time.Time { + if value == nil { + return nil + } + normalized := normalizeAdjustmentDateOnly(*value) + return &normalized +} + +func parseAdjustmentIDs(raw string) []uint { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]uint, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + value, err := strconv.ParseUint(part, 10, 64) + if err != nil || value == 0 { + continue + } + out = append(out, uint(value)) + } + return out +} + +func derefAdjustmentString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func summarizeAdjustmentApply(rows []adjustmentMigrationReportRow, groups []adjustmentTransferGroup, appliedGroups int) adjustmentApplySummary { + summary := adjustmentApplySummary{ + GroupsPlanned: len(groups), + GroupsApplied: appliedGroups, + } + for _, row := range rows { + switch row.Status { + case "eligible": + summary.RowsPlanned++ + case "applied": + summary.RowsPlanned++ + summary.RowsApplied++ + case "failed": + summary.RowsPlanned++ + summary.RowsFailed++ + case "skipped": + summary.RowsSkipped++ + } + } + return summary +} + +func flattenAdjustmentGroups(groups []adjustmentTransferGroup, fallback []adjustmentMigrationReportRow) []adjustmentMigrationReportRow { + if len(groups) == 0 { + return fallback + } + rows := make([]adjustmentMigrationReportRow, 0, len(fallback)) + for _, group := range groups { + for _, row := range group.Rows { + rows = append(rows, *row) + } + } + for _, row := range fallback { + if row.Status == "skipped" { + rows = append(rows, row) + } + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].LocationName == rows[j].LocationName { + if rows[i].SourceWarehouseName == rows[j].SourceWarehouseName { + return rows[i].ProductName < rows[j].ProductName + } + return rows[i].SourceWarehouseName < rows[j].SourceWarehouseName + } + return rows[i].LocationName < rows[j].LocationName + }) + return rows +} + +func renderAdjustmentMigrationReport(mode string, rows []adjustmentMigrationReportRow, summary adjustmentApplySummary) { + if mode == adjustmentOutputModeJSON { + payload := map[string]any{ + "rows": rows, + "summary": summary, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(payload) + return + } + + w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0) + fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tREMAINING_QTY\tCURRENT_PW_QTY\tVERIFIED_QTY\tLOCATION_STATUS\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER") + for _, row := range rows { + transferID := "-" + if row.TransferID != nil { + transferID = fmt.Sprintf("%d", *row.TransferID) + } + movementNumber := "-" + if row.MovementNumber != nil { + movementNumber = *row.MovementNumber + } + fmt.Fprintf( + w, + "%s\t%s\t%s\t%s\t%s\t%.3f\t%.3f\t%.3f\t%s\t%s\t%s\t%s\t%s\n", + row.RunID, + row.LocationName, + row.SourceWarehouseName, + derefAdjustmentString(row.FarmWarehouseName), + row.ProductName, + row.RemainingQty, + row.CurrentPWQty, + row.VerifiedQty, + row.LocationStatus, + row.Status, + row.Reason, + transferID, + movementNumber, + ) + } + _ = w.Flush() + + fmt.Printf( + "\nSUMMARY: rows_planned=%d rows_applied=%d rows_skipped=%d rows_failed=%d groups_planned=%d groups_applied=%d\n", + summary.RowsPlanned, + summary.RowsApplied, + summary.RowsSkipped, + summary.RowsFailed, + summary.GroupsPlanned, + summary.GroupsApplied, + ) +} + +func renderAdjustmentRollbackReport(mode string, rows []adjustmentRollbackDetailRow) { + if mode == adjustmentOutputModeJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(map[string]any{"rows": rows}) + return + } + + w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0) + fmt.Fprintln(w, "RUN_ID\tTRANSFER_ID\tMOVEMENT_NUMBER\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tSTATUS\tREASON") + for _, row := range rows { + fmt.Fprintf( + w, + "%s\t%d\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\n", + row.RunID, + row.TransferID, + row.MovementNumber, + row.LocationName, + row.SourceWarehouseName, + row.FarmWarehouseName, + row.ProductName, + row.Qty, + row.Status, + row.Reason, + ) + } + _ = w.Flush() +} diff --git a/cmd/migrate-legacy-egg-adjustment-stock-to-farm/main_test.go b/cmd/migrate-legacy-egg-adjustment-stock-to-farm/main_test.go new file mode 100644 index 00000000..47f12504 --- /dev/null +++ b/cmd/migrate-legacy-egg-adjustment-stock-to-farm/main_test.go @@ -0,0 +1,212 @@ +package main + +import ( + "context" + "testing" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" +) + +func TestValidateAdjustmentGatherAgainstAllowedIDsEligible(t *testing.T) { + result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11, 12}, []commonSvc.FifoStockV2GatherRow{ + {SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 70}, + {SourceTable: "adjustment_stocks", SourceID: 12, AvailableQuantity: 40}, + }) + + if result.Status != "eligible" { + t.Fatalf("expected eligible, got %+v", result) + } + if result.VerifiedQty != 100 { + t.Fatalf("expected verified qty 100, got %v", result.VerifiedQty) + } +} + +func TestValidateAdjustmentGatherAgainstAllowedIDsRejectsMixedSource(t *testing.T) { + result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11}, []commonSvc.FifoStockV2GatherRow{ + {SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 60}, + {SourceTable: "recording_eggs", SourceID: 21, AvailableQuantity: 50}, + }) + + if result.Status != "skipped" { + t.Fatalf("expected skipped, got %+v", result) + } + if result.Reason != "mixed_fifo_source_recording_eggs" { + t.Fatalf("unexpected reason: %+v", result) + } +} + +func TestBuildAdjustmentMigrationPlanUsesValidator(t *testing.T) { + opts := &adjustmentCommandOptions{RunID: "egg-adjustment-cutover-test"} + farmID := uint(25) + farmName := "Gudang Farm Jamali" + rows := []adjustmentLegacyEggRow{ + { + LocationID: 16, + LocationName: "Jamali", + SourceWarehouseID: 46, + SourceWarehouseName: "Gudang Jamali 1", + FarmWarehouseID: &farmID, + FarmWarehouseName: &farmName, + ProductWarehouseID: 101, + ProductID: 8, + ProductName: "Telur Utuh", + RemainingQty: 120, + CurrentPWQty: 150, + AdjustmentIDs: []uint{1}, + }, + { + LocationID: 16, + LocationName: "Jamali", + SourceWarehouseID: 46, + SourceWarehouseName: "Gudang Jamali 1", + FarmWarehouseID: &farmID, + FarmWarehouseName: &farmName, + ProductWarehouseID: 102, + ProductID: 9, + ProductName: "Telur Putih", + RemainingQty: 20, + CurrentPWQty: 40, + AdjustmentIDs: []uint{2}, + }, + { + LocationID: 16, + LocationName: "Jamali", + SourceWarehouseID: 46, + SourceWarehouseName: "Gudang Jamali 1", + ProductWarehouseID: 103, + ProductID: 10, + ProductName: "Telur Pecah", + RemainingQty: 10, + CurrentPWQty: 10, + AdjustmentIDs: []uint{3}, + }, + } + validator := &fakeAdjustmentCandidateValidator{ + byProduct: map[string]adjustmentCandidateValidation{ + "Telur Utuh": {Status: "eligible", VerifiedQty: 120}, + "Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10}, + }, + } + + reportRows, groups := buildAdjustmentMigrationPlan(context.Background(), opts, map[uint]adjustmentLocationTiming{ + 16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"}, + }, rows, validator) + + if len(reportRows) != 3 { + t.Fatalf("expected 3 report rows, got %d", len(reportRows)) + } + if len(groups) != 1 || len(groups[0].Rows) != 1 { + t.Fatalf("expected only one eligible grouped row, got %+v", groups) + } + if reportRows[0].Status != "eligible" || reportRows[0].VerifiedQty != 120 { + t.Fatalf("unexpected first row: %+v", reportRows[0]) + } + if reportRows[1].Reason != "mixed_fifo_source_recording_eggs" { + t.Fatalf("unexpected second row reason: %+v", reportRows[1]) + } + if reportRows[2].Reason != "missing_farm_warehouse" { + t.Fatalf("expected missing farm warehouse skip, got %+v", reportRows[2]) + } +} + +func TestExecuteAdjustmentApplyRevalidatesRowsAndAppliesSubset(t *testing.T) { + opts := &adjustmentCommandOptions{ + RunID: "egg-adjustment-cutover-apply", + CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC), + ActorID: 99, + } + group := adjustmentTransferGroup{ + LocationID: 16, + LocationName: "Jamali", + SourceWarehouseID: 46, + SourceWarehouseName: "Gudang Jamali 1", + FarmWarehouseID: 25, + FarmWarehouseName: "Gudang Farm Jamali", + Rows: []*adjustmentMigrationReportRow{ + {LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 101, ProductID: 8, ProductName: "Telur Utuh", RemainingQty: 120, CurrentPWQty: 150, AdjustmentIDs: []uint{1}, Status: "eligible"}, + {LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 102, ProductID: 9, ProductName: "Telur Putih", RemainingQty: 20, CurrentPWQty: 40, AdjustmentIDs: []uint{2}, Status: "eligible"}, + }, + } + validator := &fakeAdjustmentCandidateValidator{ + byProduct: map[string]adjustmentCandidateValidation{ + "Telur Utuh": {Status: "eligible", VerifiedQty: 120}, + "Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10}, + }, + } + executor := &fakeAdjustmentSystemTransferExecutor{ + createResponses: []*entity.StockTransfer{ + {Id: 1001, MovementNumber: "PND-LTI-1001"}, + }, + } + + summary, err := executeAdjustmentApply(context.Background(), executor, validator, opts, []adjustmentTransferGroup{group}) + if err != nil { + t.Fatalf("expected no fatal apply error, got %v", err) + } + if summary.GroupsApplied != 1 { + t.Fatalf("expected 1 applied group, got %+v", summary) + } + if summary.RowsApplied != 1 || summary.RowsFailed != 1 { + t.Fatalf("unexpected summary: %+v", summary) + } + if len(executor.createRequests) != 1 { + t.Fatalf("expected 1 create request, got %d", len(executor.createRequests)) + } + if len(executor.createRequests[0].Products) != 1 || executor.createRequests[0].Products[0].ProductID != 8 { + t.Fatalf("expected only Telur Utuh to be transferred, got %+v", executor.createRequests[0].Products) + } +} + +type fakeAdjustmentCandidateValidator struct { + byProduct map[string]adjustmentCandidateValidation + errByProduct map[string]error +} + +func (f *fakeAdjustmentCandidateValidator) ValidateCandidate(ctx context.Context, row adjustmentLegacyEggRow) (adjustmentCandidateValidation, error) { + if err, ok := f.errByProduct[row.ProductName]; ok { + return adjustmentCandidateValidation{}, err + } + if result, ok := f.byProduct[row.ProductName]; ok { + return result, nil + } + return adjustmentCandidateValidation{Status: "eligible", VerifiedQty: row.RemainingQty}, nil +} + +type fakeAdjustmentSystemTransferExecutor struct { + createRequests []*transferSvc.SystemTransferRequest + createResponses []*entity.StockTransfer + createErrors []error + deletedTransferIDs []uint + deleteErrors map[uint]error +} + +func (f *fakeAdjustmentSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) { + f.createRequests = append(f.createRequests, req) + idx := len(f.createRequests) - 1 + if idx < len(f.createErrors) && f.createErrors[idx] != nil { + return nil, f.createErrors[idx] + } + if idx < len(f.createResponses) && f.createResponses[idx] != nil { + return f.createResponses[idx], nil + } + return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil +} + +func (f *fakeAdjustmentSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error { + f.deletedTransferIDs = append(f.deletedTransferIDs, id) + if f.deleteErrors == nil { + return nil + } + return f.deleteErrors[id] +} + +func uintPtr(v uint) *uint { return &v } +func strPtr(v string) *string { return &v } + +var _ adjustmentCandidateValidator = (*fakeAdjustmentCandidateValidator)(nil) +var _ adjustmentSystemTransferExecutor = (*fakeAdjustmentSystemTransferExecutor)(nil) +var _ commonSvc.FifoStockV2Lane = fifoStockV2.LaneStockable