package main import ( "context" "errors" "strings" "testing" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" ) func TestBuildMigrationPlanSkipsOverlapAndGroupsEligibleRows(t *testing.T) { opts := &commandOptions{ RunID: "egg-cutover-test", IncludeOverlap: false, } timings := map[uint]locationTiming{ 16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"}, 17: {LocationID: 17, LocationName: "Cijangkar", Status: "OVERLAP"}, } farmID := uint(25) farmName := "Gudang Farm Jamali" rows := []legacyEggStockRow{ { LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: &farmID, FarmWarehouseName: &farmName, ProductWarehouseID: 101, ProductID: 8, ProductName: "Telur Utuh", OnHandQty: 120, }, { LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: &farmID, FarmWarehouseName: &farmName, ProductWarehouseID: 102, ProductID: 9, ProductName: "Telur Putih", OnHandQty: 20, }, { LocationID: 17, LocationName: "Cijangkar", SourceWarehouseID: 51, SourceWarehouseName: "Gudang Cijangkar 1", FarmWarehouseID: &farmID, FarmWarehouseName: &farmName, ProductWarehouseID: 103, ProductID: 10, ProductName: "Telur Jumbo", OnHandQty: 10, }, { LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", ProductWarehouseID: 104, ProductID: 11, ProductName: "Telur Papacal", OnHandQty: 50, }, { LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: &farmID, FarmWarehouseName: &farmName, ProductWarehouseID: 105, ProductID: 12, ProductName: "Telur Retak", OnHandQty: 0, }, } reportRows, groups := buildMigrationPlan(opts, timings, rows) if len(reportRows) != 5 { t.Fatalf("expected 5 report rows, got %d", len(reportRows)) } if len(groups) != 1 { t.Fatalf("expected 1 eligible transfer group, got %d", len(groups)) } if len(groups[0].Rows) != 2 { t.Fatalf("expected 2 eligible products in the transfer group, got %d", len(groups[0].Rows)) } statusByProduct := make(map[string]string, len(reportRows)) reasonByProduct := make(map[string]string, len(reportRows)) for _, row := range reportRows { statusByProduct[row.ProductName] = row.Status reasonByProduct[row.ProductName] = row.Reason } if statusByProduct["Telur Utuh"] != "eligible" || statusByProduct["Telur Putih"] != "eligible" { t.Fatalf("expected Jamali egg rows to stay eligible, got statuses %+v", statusByProduct) } if reasonByProduct["Telur Jumbo"] != "overlap_location" { t.Fatalf("expected overlap location skip, got %q", reasonByProduct["Telur Jumbo"]) } if reasonByProduct["Telur Papacal"] != "missing_farm_warehouse" { t.Fatalf("expected missing farm warehouse skip, got %q", reasonByProduct["Telur Papacal"]) } if reasonByProduct["Telur Retak"] != "non_positive_qty" { t.Fatalf("expected non positive qty skip, got %q", reasonByProduct["Telur Retak"]) } } func TestExecuteApplyBuildsTaggedSystemTransfersAndSummaries(t *testing.T) { opts := &commandOptions{ RunID: "egg-cutover-apply", CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC), ActorID: 99, } groups := []transferGroup{ { LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: 25, FarmWarehouseName: "Gudang Farm Jamali", Rows: []*migrationReportRow{ {ProductID: 8, ProductName: "Telur Utuh", Qty: 120}, {ProductID: 9, ProductName: "Telur Putih", Qty: 20}, }, }, { LocationID: 18, LocationName: "Tamansari", SourceWarehouseID: 91, SourceWarehouseName: "Gudang Tamansari 1", FarmWarehouseID: 31, FarmWarehouseName: "Gudang Farm Tamansari", Rows: []*migrationReportRow{ {ProductID: 10, ProductName: "Telur Jumbo", Qty: 10}, }, }, } executor := &fakeSystemTransferExecutor{ createResponses: []*entity.StockTransfer{ {Id: 1001, MovementNumber: "PND-LTI-1001"}, }, createErrors: []error{ nil, errors.New("destination warehouse locked"), }, } summary, err := executeApply(context.Background(), executor, opts, groups) if err != nil { t.Fatalf("expected no fatal apply error, got %v", err) } if summary.GroupsPlanned != 2 || summary.GroupsApplied != 1 { t.Fatalf("unexpected group summary: %+v", summary) } if summary.RowsApplied != 2 || summary.RowsFailed != 1 { t.Fatalf("unexpected row summary: %+v", summary) } if len(executor.createRequests) != 2 { t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests)) } if !strings.Contains(executor.createRequests[0].TransferReason, "EGG_FARM_CUTOVER|run_id=egg-cutover-apply|location=Jamali|cutover_date=2026-04-07") { t.Fatalf("unexpected transfer reason: %s", executor.createRequests[0].TransferReason) } if executor.createRequests[0].MovementNumber != "" { t.Fatalf("apply path should let transfer service generate movement number, got %q", executor.createRequests[0].MovementNumber) } if groups[0].Rows[0].Status != "applied" || groups[0].Rows[1].Status != "applied" { t.Fatalf("expected first group rows to be applied, got %+v", groups[0].Rows) } if groups[1].Rows[0].Status != "failed" { t.Fatalf("expected second group row to fail, got %+v", groups[1].Rows[0]) } if groups[0].Rows[0].TransferID == nil || *groups[0].Rows[0].TransferID != 1001 { t.Fatalf("expected first row to keep created transfer id, got %+v", groups[0].Rows[0].TransferID) } } func TestExecuteRollbackDeletesTransfersDescendingAndMarksFailures(t *testing.T) { executor := &fakeSystemTransferExecutor{ deleteErrors: map[uint]error{ 101: errors.New("already consumed downstream"), }, } rows := []rollbackDetailRow{ {TransferID: 100, ProductName: "Telur Utuh"}, {TransferID: 101, ProductName: "Telur Jumbo"}, {TransferID: 100, ProductName: "Telur Putih"}, } err := executeRollback(context.Background(), executor, rows, 99) if err == nil { t.Fatal("expected rollback to return the first transfer error") } if err.Error() != "already consumed downstream" { t.Fatalf("unexpected rollback error: %v", err) } if len(executor.deletedTransferIDs) != 2 { t.Fatalf("expected 2 delete calls, got %d", len(executor.deletedTransferIDs)) } if executor.deletedTransferIDs[0] != 101 || executor.deletedTransferIDs[1] != 100 { t.Fatalf("expected delete order [101 100], got %v", executor.deletedTransferIDs) } if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" { t.Fatalf("expected transfer 100 rows to be rolled back, got %+v", rows) } if rows[1].Status != "failed" { t.Fatalf("expected transfer 101 row to fail, got %+v", rows[1]) } } type fakeSystemTransferExecutor struct { createRequests []*transferSvc.SystemTransferRequest createResponses []*entity.StockTransfer createErrors []error deletedTransferIDs []uint deleteErrors map[uint]error } func (f *fakeSystemTransferExecutor) 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 *fakeSystemTransferExecutor) 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] }