diff --git a/cmd/migrate-legacy-egg-stock-to-farm/main.go b/cmd/migrate-legacy-egg-stock-to-farm/main.go new file mode 100644 index 00000000..27508b1f --- /dev/null +++ b/cmd/migrate-legacy-egg-stock-to-farm/main.go @@ -0,0 +1,825 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "os" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/go-playground/validator/v10" + "github.com/sirupsen/logrus" + "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "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" + "gorm.io/gorm" +) + +const ( + cutoverReasonPrefix = "EGG_FARM_CUTOVER" + outputModeTable = "table" + outputModeJSON = "json" +) + +type commandOptions struct { + Apply bool + DryRun bool + RollbackRunID string + LocationID uint + LocationName string + CutoverDate time.Time + CutoverDateRaw string + IncludeOverlap bool + Output string + ActorID uint + RunID string +} + +type locationTiming struct { + LocationID uint + LocationName string + FirstKandangDate *time.Time + LastKandangDate *time.Time + FirstFarmDate *time.Time + LastFarmDate *time.Time + Status string +} + +type legacyEggStockRow struct { + LocationID uint + LocationName string + SourceWarehouseID uint + SourceWarehouseName string + FarmWarehouseID *uint + FarmWarehouseName *string + ProductWarehouseID uint + ProductID uint + ProductName string + OnHandQty float64 +} + +type migrationReportRow 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"` + Qty float64 `json:"qty"` + LocationStatus string `json:"location_status"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + TransferID *uint64 `json:"transfer_id,omitempty"` + MovementNumber *string `json:"movement_number,omitempty"` +} + +type applySummary 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 rollbackDetailRow 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 systemTransferExecutor interface { + CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) + DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error +} + +type transferGroup struct { + LocationID uint + LocationName string + SourceWarehouseID uint + SourceWarehouseName string + FarmWarehouseID uint + FarmWarehouseName string + Rows []*migrationReportRow +} + +func main() { + opts, err := parseFlags() + if err != nil { + log.Fatalf("invalid flags: %v", err) + } + + db := database.Connect(config.DBHost, config.DBName) + ctx := context.Background() + + if strings.TrimSpace(opts.RollbackRunID) != "" { + rows, err := loadRollbackDetails(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" + } + renderRollbackReport(opts.Output, rows) + return + } + if err := executeRollback(ctx, newSystemTransferService(db), rows, opts.ActorID); err != nil { + log.Fatalf("rollback failed: %v", err) + } + renderRollbackReport(opts.Output, rows) + return + } + + timings, err := loadLocationTimings(ctx, db, opts) + if err != nil { + log.Fatalf("failed to load location timings: %v", err) + } + legacyRows, err := loadLegacyEggStocks(ctx, db, opts) + if err != nil { + log.Fatalf("failed to load legacy egg stocks: %v", err) + } + + reportRows, groups := buildMigrationPlan(opts, timings, legacyRows) + if !opts.Apply { + renderMigrationReport(opts.Output, reportRows, summarizeApply(reportRows, groups, 0)) + return + } + + summary, err := executeApply(ctx, newSystemTransferService(db), opts, groups) + if err != nil { + log.Fatalf("apply failed: %v", err) + } + finalRows := flattenGroups(groups, reportRows) + summary = summarizeApply(finalRows, groups, summary.GroupsApplied) + renderMigrationReport(opts.Output, finalRows, summary) +} + +func parseFlags() (*commandOptions, error) { + var opts commandOptions + 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.BoolVar(&opts.IncludeOverlap, "include-overlap", false, "Include overlap locations in plan/apply") + flag.StringVar(&opts.Output, "output", outputModeTable, "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 = outputModeTable + } + if opts.Output != outputModeTable && opts.Output != outputModeJSON { + 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 = normalizeDateOnly(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 = normalizeDateOnly(t) + } + + opts.RunID = buildRunID() + return &opts, nil +} + +func newSystemTransferService(db *gorm.DB) systemTransferExecutor { + 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 := service.NewFifoStockV2Service(db, logrus.StandardLogger()) + + return transferSvc.NewTransferService( + validate, + stockTransferRepo, + stockTransferDetailRepo, + stockTransferDeliveryRepo, + stockTransferDeliveryItemRepo, + stockLogsRepo, + productWarehouseRepo, + nil, + warehouseRepository, + projectFlockKandangRepo, + projectFlockPopulationRepo, + nil, + fifoSvc, + nil, + ) +} + +func loadLocationTimings(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]locationTiming, 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 = applyTimingLocationFilter(query, opts) + + var rows []row + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]locationTiming, len(rows)) + for _, row := range rows { + status := "KANDANG_ONLY" + if row.FirstFarmDate != nil { + status = "OVERLAP" + if row.LastKandangDate == nil || row.FirstFarmDate.After(normalizeDateOnly(*row.LastKandangDate)) { + status = "CLEAN_CUTOVER" + } + } + result[row.LocationID] = locationTiming{ + LocationID: row.LocationID, + LocationName: row.LocationName, + FirstKandangDate: normalizeDatePtr(row.FirstKandangDate), + LastKandangDate: normalizeDatePtr(row.LastKandangDate), + FirstFarmDate: normalizeDatePtr(row.FirstFarmDate), + LastFarmDate: normalizeDatePtr(row.LastFarmDate), + Status: status, + } + } + + return result, nil +} + +func loadLegacyEggStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]legacyEggStockRow, 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"` + OnHandQty float64 `gorm:"column:on_hand_qty"` + } + + 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("product_warehouses pw"). + 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 on_hand_qty + `). + 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(` + EXISTS ( + SELECT 1 + FROM recording_eggs re + WHERE re.product_warehouse_id = pw.id + ) + `). + 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). + Order("l.name ASC, kw.name ASC, p.name ASC") + query = applyLegacyStockLocationFilter(query, opts) + + var rows []row + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + result := make([]legacyEggStockRow, 0, len(rows)) + for _, row := range rows { + result = append(result, legacyEggStockRow{ + 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, + OnHandQty: row.OnHandQty, + }) + } + + return result, nil +} + +func buildMigrationPlan( + opts *commandOptions, + timings map[uint]locationTiming, + rows []legacyEggStockRow, +) ([]migrationReportRow, []transferGroup) { + reportRows := make([]migrationReportRow, 0, len(rows)) + groupMap := make(map[string]*transferGroup) + + for _, row := range rows { + locationStatus := "UNKNOWN" + if timing, ok := timings[row.LocationID]; ok { + locationStatus = timing.Status + } + + report := migrationReportRow{ + 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, + Qty: row.OnHandQty, + LocationStatus: locationStatus, + Status: "eligible", + } + + switch { + case row.FarmWarehouseID == nil || row.FarmWarehouseName == nil: + report.Status = "skipped" + report.Reason = "missing_farm_warehouse" + case row.OnHandQty <= 0: + report.Status = "skipped" + report.Reason = "non_positive_qty" + case locationStatus == "OVERLAP" && !opts.IncludeOverlap: + report.Status = "skipped" + report.Reason = "overlap_location" + } + + 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 = &transferGroup{ + LocationID: row.LocationID, + LocationName: row.LocationName, + SourceWarehouseID: row.SourceWarehouseID, + SourceWarehouseName: row.SourceWarehouseName, + FarmWarehouseID: *row.FarmWarehouseID, + FarmWarehouseName: derefString(row.FarmWarehouseName), + } + groupMap[groupKey] = group + } + group.Rows = append(group.Rows, &reportRows[len(reportRows)-1]) + } + + groups := make([]transferGroup, 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 executeApply( + ctx context.Context, + svc systemTransferExecutor, + opts *commandOptions, + groups []transferGroup, +) (applySummary, error) { + summary := applySummary{GroupsPlanned: len(groups)} + for _, group := range groups { + products := make([]transferSvc.SystemTransferProduct, 0, len(group.Rows)) + for _, row := range group.Rows { + products = append(products, transferSvc.SystemTransferProduct{ + ProductID: row.ProductID, + ProductQty: row.Qty, + }) + } + reason := buildCutoverReason(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 group.Rows { + row.Status = "failed" + row.Reason = err.Error() + summary.RowsFailed++ + } + continue + } + + summary.GroupsApplied++ + for _, row := range group.Rows { + row.Status = "applied" + row.TransferID = &transfer.Id + row.MovementNumber = &transfer.MovementNumber + summary.RowsApplied++ + } + } + + for _, group := range groups { + summary.RowsPlanned += len(group.Rows) + } + return summary, nil +} + +func executeRollback( + ctx context.Context, + svc systemTransferExecutor, + rows []rollbackDetailRow, + 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 loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, 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 := buildRunReasonMatcher(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([]rollbackDetailRow, 0, len(dbRows)) + for _, row := range dbRows { + rows = append(rows, rollbackDetailRow{ + 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 applyTimingLocationFilter(db *gorm.DB, opts *commandOptions) *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 applyLegacyStockLocationFilter(db *gorm.DB, opts *commandOptions) *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 buildCutoverReason(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", cutoverReasonPrefix, runID, locationName, cutoverDate.Format("2006-01-02")) +} + +func buildRunReasonMatcher(runID string) string { + return fmt.Sprintf("%s|run_id=%s|%%", cutoverReasonPrefix, strings.TrimSpace(runID)) +} + +func buildRunID() string { + return fmt.Sprintf("egg-cutover-%s", time.Now().UTC().Format("20060102T150405.000000000Z")) +} + +func normalizeDateOnly(value time.Time) time.Time { + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC) +} + +func normalizeDatePtr(value *time.Time) *time.Time { + if value == nil { + return nil + } + normalized := normalizeDateOnly(*value) + return &normalized +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func summarizeApply(rows []migrationReportRow, groups []transferGroup, appliedGroups int) applySummary { + summary := applySummary{ + 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 flattenGroups(groups []transferGroup, fallback []migrationReportRow) []migrationReportRow { + if len(groups) == 0 { + return fallback + } + rows := make([]migrationReportRow, 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 renderMigrationReport(mode string, rows []migrationReportRow, summary applySummary) { + if mode == outputModeJSON { + payload := map[string]any{ + "rows": rows, + "summary": summary, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(payload) + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\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%s\t%s\t%s\t%s\t%s\n", + row.RunID, + row.LocationName, + row.SourceWarehouseName, + derefString(row.FarmWarehouseName), + row.ProductName, + row.Qty, + 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 renderRollbackReport(mode string, rows []rollbackDetailRow) { + if mode == outputModeJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(map[string]any{"rows": rows}) + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 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-stock-to-farm/main_test.go b/cmd/migrate-legacy-egg-stock-to-farm/main_test.go new file mode 100644 index 00000000..47881431 --- /dev/null +++ b/cmd/migrate-legacy-egg-stock-to-farm/main_test.go @@ -0,0 +1,251 @@ +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] +} diff --git a/docs/legacy_egg_farm_cutover_runbook.md b/docs/legacy_egg_farm_cutover_runbook.md new file mode 100644 index 00000000..a4123b42 --- /dev/null +++ b/docs/legacy_egg_farm_cutover_runbook.md @@ -0,0 +1,286 @@ +# Runbook Cutover Stok Telur Historis Kandang ke Gudang Farm + +## Tujuan + +Runbook ini dipakai untuk memindahkan **stok telur historis yang masih on-hand di gudang kandang** ke **gudang farm** secara aman, audit-able, dan reversible. + +Cutover dilakukan dengan **transfer stok eksplisit**, bukan dengan mengubah `recording_eggs.product_warehouse_id` historis. + +## Scope + +Runbook ini hanya untuk: +- stok telur historis kandang-level yang masih punya saldo on-hand +- lokasi yang masuk kategori **clean cutover** +- lokasi yang sudah punya gudang farm + +Runbook ini **tidak** dipakai untuk: +- lokasi overlap seperti `Cijangkar` +- koreksi histori `recording_eggs` +- migrasi stok non-telur + +## Kebijakan yang Dikunci + +- Sumber qty yang dipindah adalah **`product_warehouses.qty` saat cutover** +- Perintah dijalankan **per lokasi** +- Wajib mulai dari `dry-run` +- `--apply` hanya boleh dijalankan setelah review dry-run dan SQL checklist +- Lokasi overlap tidak ikut otomatis kecuali ada approval khusus dan `--include-overlap` +- Rollback hanya boleh dilakukan jika transfer hasil cutover belum dipakai transaksi turunan + +## Lokasi Fase 1 + +Lokasi yang boleh dieksekusi pada fase pertama: +- `Jamali` +- `Cantilan` +- `Darawati` +- `Tamansari` + +Lokasi yang harus ditahan: +- `Cijangkar` + +## Prasyarat + +Sebelum eksekusi, pastikan: +- backend sudah ter-deploy dengan command [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go) +- reusable transfer core sudah ikut ter-deploy: + - [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go) + - [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go) +- migrasi farm stock attribution sebelumnya sudah terpasang +- akses database target sudah tersedia +- environment target memakai SSL bila RDS mewajibkan, contoh: + - `DB_SSLMODE=require` + +## Catatan Output Command + +Mode `--output table` adalah mode operasional yang direkomendasikan. + +Mode `--output json` bisa dipakai, tetapi pada environment saat ini output JSON masih dapat didahului log bootstrap aplikasi atau SQL logger. Untuk review manual gunakan `table`. Untuk parsing otomatis, filter payload mulai dari `{`. + +## Format Command + +### Dry-run + +```bash +DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \ + --location-name Jamali \ + --output table +``` + +### Apply + +```bash +DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \ + --location-name Jamali \ + --cutover-date 2026-04-07 \ + --apply \ + --output table +``` + +### Rollback Preview + +```bash +DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \ + --rollback-run-id \ + --output table +``` + +### Rollback Apply + +```bash +DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \ + --rollback-run-id \ + --apply \ + --output table +``` + +## Arti `run_id` + +Setiap dry-run/apply menghasilkan `run_id`, misalnya: + +```text +egg-cutover-20260407T130344.220407000Z +``` + +`run_id` ini wajib disimpan karena dipakai untuk: +- audit hasil cutover +- query verifikasi +- rollback + +## Prosedur Eksekusi Per Lokasi + +### 1. Persiapan + +Tentukan: +- `location_name` +- `cutover_date` +- operator yang bertanggung jawab + +Contoh: +- lokasi: `Jamali` +- cutover date: `2026-04-07` + +### 2. Jalankan Dry-run + +```bash +DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \ + --location-name Jamali \ + --output table +``` + +Yang harus dicek pada hasil dry-run: +- status lokasi `CLEAN_CUTOVER` +- semua baris yang akan dipindah punya `status=eligible` +- gudang tujuan adalah gudang farm lokasi tersebut +- qty yang dipindah masuk akal dan sesuai saldo on-hand aktual +- tidak ada `missing_farm_warehouse` +- tidak ada `overlap_location` + +### 3. Jalankan Checklist SQL Before + +Gunakan file: +- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql) + +Minimal pastikan: +- lokasi memang clean cutover +- stok telur kandang positif masih ada +- gudang farm ada +- belum ada transfer `EGG_FARM_CUTOVER` aktif untuk lokasi yang sama pada run yang akan dipakai + +### 4. Simpan Evidence Sebelum Apply + +Simpan: +- output dry-run +- hasil query before +- nama operator +- waktu eksekusi + +Disarankan simpan dalam ticket / change record. + +### 5. Jalankan Apply + +```bash +DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \ + --location-name Jamali \ + --cutover-date 2026-04-07 \ + --apply \ + --output table +``` + +Setelah apply, simpan: +- `run_id` +- seluruh row dengan `transfer_id` +- movement number yang terbentuk + +### 6. Jalankan Checklist SQL After + +Masih menggunakan file: +- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql) + +Minimal pastikan: +- transfer header/detail tercatat untuk `run_id` +- qty source berkurang sesuai transfer +- qty farm bertambah sesuai transfer +- total gabungan source+dest per produk per lokasi tetap sama +- stok eligible tidak lagi tersedia di gudang kandang +- stok telur sekarang tersedia di gudang farm + +### 7. Smoke Test UI + +Lakukan minimal: +- buka product stock farm untuk lokasi tersebut +- pastikan produk telur hasil migrasi muncul +- buat SO farm-level dan pastikan opsi produk telur tersedia +- pastikan recording telur baru setelah cutover tetap langsung masuk ke gudang farm + +### 8. Tutup Eksekusi + +Catat hasil akhir: +- sukses/gagal +- `run_id` +- lokasi +- tanggal cutover +- operator +- link ke evidence SQL/UI + +## Kriteria Go / No-Go + +### Boleh lanjut apply bila: + +- dry-run menunjukkan hanya row yang memang expected +- lokasi `CLEAN_CUTOVER` +- gudang farm valid +- query before menunjukkan tidak ada anomaly blocking + +### Wajib stop bila: + +- lokasi terdeteksi `OVERLAP` +- ada qty aneh atau tidak sesuai data lapangan +- gudang farm tidak ada +- ada transfer lama serupa yang belum direkonsiliasi +- setelah apply terjadi selisih total source+dest + +## Rollback Runbook + +### Kapan rollback boleh dilakukan + +Rollback boleh jika: +- transfer hasil cutover belum dipakai transaksi turunan +- verifikasi after menunjukkan issue yang membuat hasil cutover tidak dapat diterima + +### Langkah rollback + +1. Preview rollback: + +```bash +DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \ + --rollback-run-id \ + --output table +``` + +2. Jalankan query rollback readiness pada file audit/helper SQL. +3. Jika aman, apply rollback: + +```bash +DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \ + --rollback-run-id \ + --apply \ + --output table +``` + +4. Jalankan ulang query verifikasi after rollback. + +### Kapan rollback akan gagal by design + +Rollback memang harus gagal jika: +- transfer hasil cutover sudah dipakai sales/recording/transaksi turunan +- sudah ada `stock_allocations` consume aktif terhadap `STOCK_TRANSFER_IN` + +## Urutan Rollout yang Direkomendasikan + +### Dev + +1. Dry-run per lokasi +2. Review SQL before +3. Apply per lokasi +4. SQL after +5. Smoke UI +6. Simpan `run_id` + +### Production + +1. Freeze operasional lokasi target bila perlu +2. Dry-run +3. Review by dev + ops + finance/stock owner +4. Apply +5. SQL after +6. Smoke UI +7. Release lokasi berikutnya + +## Referensi + +- Command cutover: [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go) +- Test command: [main_test.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main_test.go) +- Core reusable transfer: [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go) +- Transfer service refactor: [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go) +- Checklist SQL: [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql) +- Helper query audit: [legacy_egg_cutover_audit_queries.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_audit_queries.sql) diff --git a/docs/sql/legacy_egg_cutover_audit_queries.sql b/docs/sql/legacy_egg_cutover_audit_queries.sql new file mode 100644 index 00000000..295da3da --- /dev/null +++ b/docs/sql/legacy_egg_cutover_audit_queries.sql @@ -0,0 +1,343 @@ +-- Legacy Egg Cutover Audit Helper Queries +-- Ad-hoc query pack for investigation, audit, dry-run review, and rollback readiness. + +-- ===================================================================== +-- AUDIT-01 All locations classified by kandang/farm egg posting timing +-- ===================================================================== +WITH timing AS ( + 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 + FROM recording_eggs re + JOIN recordings r ON r.id = re.recording_id + JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) + JOIN project_flocks pf ON pf.id = pk.project_flock_id + JOIN locations l ON l.id = pf.location_id + JOIN product_warehouses pw ON pw.id = re.product_warehouse_id + JOIN warehouses w ON w.id = pw.warehouse_id + GROUP BY pf.location_id, l.name +) +SELECT + location_id, + location_name, + first_kandang_date, + last_kandang_date, + first_farm_date, + last_farm_date, + CASE + WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY' + WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER' + ELSE 'OVERLAP' + END AS location_status +FROM timing +ORDER BY location_name; + +-- ===================================================================== +-- AUDIT-02 All legacy kandang egg product warehouses with positive on-hand +-- ===================================================================== +WITH first_farm AS ( + SELECT location_id, MIN(id) AS farm_warehouse_id + FROM warehouses + WHERE type = 'LOKASI' + AND deleted_at IS NULL + GROUP BY location_id +) +SELECT + l.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, + p.id AS product_id, + p.name AS product_name, + COALESCE(pw.qty, 0) AS on_hand_qty +FROM product_warehouses pw +JOIN warehouses kw + ON kw.id = pw.warehouse_id + AND kw.type = 'KANDANG' + AND kw.deleted_at IS NULL +JOIN locations l ON l.id = kw.location_id +JOIN products p ON p.id = pw.product_id +LEFT JOIN product_categories pc ON pc.id = p.product_category_id +LEFT JOIN first_farm ff ON ff.location_id = kw.location_id +LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id +WHERE EXISTS ( + SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id + ) + AND ( + EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + 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 = 'products' + AND f_any.flagable_id = p.id + ) + AND UPPER(COALESCE(pc.code, '')) = 'EGG' + ) + ) + AND COALESCE(pw.qty, 0) > 0 +ORDER BY l.name, kw.name, p.name; + +-- ===================================================================== +-- AUDIT-03 Totals per location for phase sizing +-- ===================================================================== +WITH candidates AS ( + SELECT + l.name AS location_name, + COALESCE(pw.qty, 0) AS on_hand_qty + FROM product_warehouses pw + JOIN warehouses kw + ON kw.id = pw.warehouse_id + AND kw.type = 'KANDANG' + AND kw.deleted_at IS NULL + JOIN locations l ON l.id = kw.location_id + JOIN products p ON p.id = pw.product_id + LEFT JOIN product_categories pc ON pc.id = p.product_category_id + WHERE EXISTS ( + SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id + ) + AND ( + EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + 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 = 'products' + AND f_any.flagable_id = p.id + ) + AND UPPER(COALESCE(pc.code, '')) = 'EGG' + ) + ) + AND COALESCE(pw.qty, 0) > 0 +) +SELECT + location_name, + COUNT(*) AS positive_rows, + SUM(on_hand_qty) AS total_on_hand_qty +FROM candidates +GROUP BY location_name +ORDER BY location_name; + +-- ===================================================================== +-- AUDIT-04 Locations missing farm warehouse +-- ===================================================================== +SELECT + l.id AS location_id, + l.name AS location_name +FROM locations l +WHERE EXISTS ( + SELECT 1 + FROM warehouses kw + WHERE kw.location_id = l.id + AND kw.type = 'KANDANG' + AND kw.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 + FROM warehouses fw + WHERE fw.location_id = l.id + AND fw.type = 'LOKASI' + AND fw.deleted_at IS NULL + ) +ORDER BY l.name; + +-- ===================================================================== +-- AUDIT-05 Legacy recording_eggs still pointing to kandang warehouse +-- ===================================================================== +SELECT + l.name AS location_name, + kw.name AS kandang_warehouse_name, + p.name AS product_name, + COUNT(*) AS recording_rows +FROM recording_eggs re +JOIN product_warehouses pw ON pw.id = re.product_warehouse_id +JOIN warehouses kw ON kw.id = pw.warehouse_id +JOIN locations l ON l.id = kw.location_id +JOIN products p ON p.id = pw.product_id +WHERE kw.type = 'KANDANG' +GROUP BY l.name, kw.name, p.name +ORDER BY l.name, kw.name, p.name; + +-- ===================================================================== +-- AUDIT-06 Farm-level recording_eggs already present +-- ===================================================================== +SELECT + l.name AS location_name, + fw.name AS farm_warehouse_name, + p.name AS product_name, + COUNT(*) AS recording_rows +FROM recording_eggs re +JOIN product_warehouses pw ON pw.id = re.product_warehouse_id +JOIN warehouses fw ON fw.id = pw.warehouse_id +JOIN locations l ON l.id = fw.location_id +JOIN products p ON p.id = pw.product_id +WHERE fw.type = 'LOKASI' +GROUP BY l.name, fw.name, p.name +ORDER BY l.name, fw.name, p.name; + +-- ===================================================================== +-- AUDIT-07 Transfers created by cutover reason, grouped by run_id +-- ===================================================================== +SELECT + SPLIT_PART(SPLIT_PART(st.reason, '|run_id=', 2), '|', 1) AS run_id, + COUNT(DISTINCT st.id) AS transfer_count, + COUNT(std.id) AS detail_count, + SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty, + MIN(st.transfer_date) AS first_transfer_date, + MAX(st.transfer_date) AS last_transfer_date +FROM stock_transfers st +JOIN stock_transfer_details std + ON std.stock_transfer_id = st.id + AND std.deleted_at IS NULL +WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=%' +GROUP BY 1 +ORDER BY first_transfer_date DESC, run_id DESC; + +-- ===================================================================== +-- AUDIT-08 Detailed summary per run_id +-- Replace before running. +-- ===================================================================== +SELECT + st.id AS transfer_id, + st.movement_number, + st.transfer_date, + 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 moved_qty, + st.deleted_at +FROM stock_transfers st +JOIN stock_transfer_details std + ON std.stock_transfer_id = st.id + AND std.deleted_at IS NULL +JOIN products p ON p.id = std.product_id +JOIN warehouses ws ON ws.id = st.from_warehouse_id +JOIN warehouses wd ON wd.id = st.to_warehouse_id +WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=|%' +ORDER BY st.id, p.name; + +-- ===================================================================== +-- AUDIT-09 Downstream consumption check per run_id +-- Replace before running. +-- ===================================================================== +SELECT + st.id AS transfer_id, + st.movement_number, + p.name AS product_name, + sa.usable_type, + sa.usable_id, + sa.qty, + sa.function_code, + sa.flag_group_code +FROM stock_transfers st +JOIN stock_transfer_details std + ON std.stock_transfer_id = st.id + AND std.deleted_at IS NULL +JOIN products p ON p.id = std.product_id +JOIN stock_allocations sa + ON sa.stockable_type = 'STOCK_TRANSFER_IN' + AND sa.stockable_id = std.id + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + AND sa.deleted_at IS NULL +WHERE st.deleted_at IS NULL + AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=|%' +ORDER BY st.id, p.name, sa.usable_type, sa.usable_id; + +-- ===================================================================== +-- AUDIT-10 Stock log reconciliation per cutover transfer detail +-- Replace before running. +-- ===================================================================== +SELECT + st.id AS transfer_id, + st.movement_number, + p.name AS product_name, + std.id AS transfer_detail_id, + COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty, + SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END) AS total_logged_out, + SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END) AS total_logged_in +FROM stock_transfers st +JOIN stock_transfer_details std + ON std.stock_transfer_id = st.id + AND std.deleted_at IS NULL +JOIN products p ON p.id = std.product_id +LEFT JOIN stock_logs sl + ON sl.loggable_type = 'TRANSFER' + AND sl.loggable_id = std.id +WHERE st.deleted_at IS NULL + AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=|%' +GROUP BY st.id, st.movement_number, p.name, std.id, COALESCE(std.total_qty, std.usage_qty, 0) +ORDER BY st.id, p.name; + +-- ===================================================================== +-- AUDIT-11 New recording eggs still posting to kandang after cutoff date +-- Replace values before running. +-- ===================================================================== +SELECT + DATE(r.record_datetime) AS record_date, + l.name AS location_name, + kw.name AS kandang_warehouse_name, + p.name AS product_name, + re.qty +FROM recording_eggs re +JOIN recordings r ON r.id = re.recording_id +JOIN product_warehouses pw ON pw.id = re.product_warehouse_id +JOIN warehouses kw ON kw.id = pw.warehouse_id +JOIN locations l ON l.id = kw.location_id +JOIN products p ON p.id = pw.product_id +WHERE kw.type = 'KANDANG' + AND LOWER(l.name) = LOWER('') + AND DATE(r.record_datetime) >= DATE('') +ORDER BY r.record_datetime ASC, kw.name, p.name; + +-- Expectation: +-- - after deploy and cutover, this should ideally return 0 rows for the location + +-- ===================================================================== +-- AUDIT-12 Combined kandang + farm egg stock per location after cutover +-- Replace before running. +-- ===================================================================== +SELECT + l.name AS location_name, + w.type AS warehouse_type, + p.name AS product_name, + SUM(COALESCE(pw.qty, 0)) AS total_qty +FROM product_warehouses pw +JOIN warehouses w ON w.id = pw.warehouse_id +JOIN locations l ON l.id = w.location_id +JOIN products p ON p.id = pw.product_id +LEFT JOIN product_categories pc ON pc.id = p.product_category_id +WHERE LOWER(l.name) = LOWER('') + AND ( + EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + 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 = 'products' + AND f_any.flagable_id = p.id + ) + AND UPPER(COALESCE(pc.code, '')) = 'EGG' + ) + ) +GROUP BY l.name, w.type, p.name +ORDER BY w.type, p.name; diff --git a/docs/sql/legacy_egg_cutover_verification_checklist.sql b/docs/sql/legacy_egg_cutover_verification_checklist.sql new file mode 100644 index 00000000..7512f352 --- /dev/null +++ b/docs/sql/legacy_egg_cutover_verification_checklist.sql @@ -0,0 +1,400 @@ +-- Legacy Egg Cutover Verification Checklist +-- Usage: +-- 1. Replace the values below before executing. +-- 2. Run section BEFORE before --apply. +-- 3. Run section AFTER after --apply. +-- 4. Run rollback checks if needed. + +-- ===================================================================== +-- PARAMETERS +-- ===================================================================== + +-- Replace manually before running. +-- Example: +-- location_name = Jamali +-- cutover_date = 2026-04-07 +-- run_id = egg-cutover-20260407T130344.220407000Z + +-- ===================================================================== +-- BEFORE APPLY +-- ===================================================================== + +-- [BEFORE-01] Identify target location and farm warehouse +SELECT + l.id AS location_id, + l.name AS location_name, + fw.id AS farm_warehouse_id, + fw.name AS farm_warehouse_name +FROM locations l +LEFT JOIN warehouses fw + ON fw.location_id = l.id + AND fw.type = 'LOKASI' + AND fw.deleted_at IS NULL +WHERE LOWER(l.name) = LOWER('') +ORDER BY fw.id ASC; + +-- Expectation: +-- - exactly one target location +-- - at least one farm warehouse exists + +-- [BEFORE-02] Verify location timing status (must be CLEAN_CUTOVER for phase 1) +WITH timing AS ( + 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 + FROM recording_eggs re + JOIN recordings r ON r.id = re.recording_id + JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) + JOIN project_flocks pf ON pf.id = pk.project_flock_id + JOIN locations l ON l.id = pf.location_id + JOIN product_warehouses pw ON pw.id = re.product_warehouse_id + JOIN warehouses w ON w.id = pw.warehouse_id + WHERE LOWER(l.name) = LOWER('') + GROUP BY pf.location_id, l.name +) +SELECT + location_id, + location_name, + first_kandang_date, + last_kandang_date, + first_farm_date, + last_farm_date, + CASE + WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY' + WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER' + ELSE 'OVERLAP' + END AS location_status +FROM timing; + +-- Expectation: +-- - phase 1 location must be CLEAN_CUTOVER + +-- [BEFORE-03] Candidate source rows that should be migrated +WITH first_farm AS ( + SELECT location_id, MIN(id) AS farm_warehouse_id + FROM warehouses + WHERE type = 'LOKASI' + AND deleted_at IS NULL + GROUP BY location_id +) +SELECT + l.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, + p.id AS product_id, + p.name AS product_name, + COALESCE(pw.qty, 0) AS on_hand_qty +FROM product_warehouses pw +JOIN warehouses kw + ON kw.id = pw.warehouse_id + AND kw.type = 'KANDANG' + AND kw.deleted_at IS NULL +JOIN locations l ON l.id = kw.location_id +JOIN products p ON p.id = pw.product_id +LEFT JOIN product_categories pc ON pc.id = p.product_category_id +LEFT JOIN first_farm ff ON ff.location_id = kw.location_id +LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id +WHERE LOWER(l.name) = LOWER('') + AND EXISTS ( + SELECT 1 + FROM recording_eggs re + WHERE re.product_warehouse_id = pw.id + ) + AND ( + EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_type = 'products' + 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 = 'products' + AND f_any.flagable_id = p.id + ) + AND UPPER(COALESCE(pc.code, '')) = 'EGG' + ) + ) + AND COALESCE(pw.qty, 0) > 0 +ORDER BY kw.name, p.name; + +-- Expectation: +-- - every row here should match dry-run eligible rows + +-- [BEFORE-04] Totals per source warehouse and product +WITH candidates AS ( + SELECT + kw.name AS source_warehouse_name, + p.name AS product_name, + COALESCE(pw.qty, 0) AS on_hand_qty + FROM product_warehouses pw + JOIN warehouses kw + ON kw.id = pw.warehouse_id + AND kw.type = 'KANDANG' + AND kw.deleted_at IS NULL + JOIN locations l ON l.id = kw.location_id + JOIN products p ON p.id = pw.product_id + LEFT JOIN product_categories pc ON pc.id = p.product_category_id + WHERE LOWER(l.name) = LOWER('') + AND EXISTS ( + SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id + ) + AND ( + EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + 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 = 'products' + AND f_any.flagable_id = p.id + ) + AND UPPER(COALESCE(pc.code, '')) = 'EGG' + ) + ) + AND COALESCE(pw.qty, 0) > 0 +) +SELECT + source_warehouse_name, + product_name, + SUM(on_hand_qty) AS total_qty +FROM candidates +GROUP BY source_warehouse_name, product_name +ORDER BY source_warehouse_name, product_name; + +-- [BEFORE-05] Current farm egg stock before cutover +SELECT + fw.name AS farm_warehouse_name, + p.name AS product_name, + COALESCE(pw.qty, 0) AS farm_on_hand_qty +FROM warehouses fw +JOIN locations l ON l.id = fw.location_id +JOIN product_warehouses pw ON pw.warehouse_id = fw.id +JOIN products p ON p.id = pw.product_id +LEFT JOIN product_categories pc ON pc.id = p.product_category_id +WHERE LOWER(l.name) = LOWER('') + AND fw.type = 'LOKASI' + AND fw.deleted_at IS NULL + AND ( + EXISTS ( + SELECT 1 FROM flags f + WHERE f.flagable_type = 'products' + 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 = 'products' + AND f_any.flagable_id = p.id + ) + AND UPPER(COALESCE(pc.code, '')) = 'EGG' + ) + ) +ORDER BY p.name; + +-- [BEFORE-06] Existing cutover transfers for this location +SELECT + st.id, + st.movement_number, + st.transfer_date, + st.reason, + ws.name AS source_warehouse_name, + wd.name AS farm_warehouse_name, + st.deleted_at +FROM stock_transfers st +JOIN warehouses ws ON ws.id = st.from_warehouse_id +JOIN warehouses wd ON wd.id = st.to_warehouse_id +LEFT JOIN locations l ON l.id = COALESCE(ws.location_id, wd.location_id) +WHERE LOWER(COALESCE(l.name, '')) = LOWER('') + AND st.reason LIKE 'EGG_FARM_CUTOVER|%' +ORDER BY st.id DESC; + +-- Expectation: +-- - no unexpected older active cutover transfers for the same location + +-- ===================================================================== +-- AFTER APPLY +-- ===================================================================== + +-- [AFTER-01] Transfer headers created by run_id +SELECT + st.id, + st.movement_number, + st.transfer_date, + st.reason, + ws.name AS source_warehouse_name, + wd.name AS farm_warehouse_name, + st.deleted_at +FROM stock_transfers st +JOIN warehouses ws ON ws.id = st.from_warehouse_id +JOIN warehouses wd ON wd.id = st.to_warehouse_id +WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=|%' +ORDER BY st.id ASC; + +-- [AFTER-02] Transfer detail rows created by run_id +SELECT + st.id AS transfer_id, + st.movement_number, + 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 moved_qty, + std.source_product_warehouse_id, + std.dest_product_warehouse_id +FROM stock_transfers st +JOIN stock_transfer_details std + ON std.stock_transfer_id = st.id + AND std.deleted_at IS NULL +JOIN products p ON p.id = std.product_id +JOIN warehouses ws ON ws.id = st.from_warehouse_id +JOIN warehouses wd ON wd.id = st.to_warehouse_id +WHERE st.deleted_at IS NULL + AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=|%' +ORDER BY st.id, p.name; + +-- [AFTER-03] Stock logs created by run_id transfer details +SELECT + st.id AS transfer_id, + st.movement_number, + p.name AS product_name, + sl.product_warehouse_id, + sl.increase, + sl.decrease, + sl.stock, + sl.created_at +FROM stock_transfers st +JOIN stock_transfer_details std + ON std.stock_transfer_id = st.id + AND std.deleted_at IS NULL +JOIN products p ON p.id = std.product_id +JOIN stock_logs sl + ON sl.loggable_type = 'TRANSFER' + AND sl.loggable_id = std.id +WHERE st.deleted_at IS NULL + AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=|%' +ORDER BY st.id, p.name, sl.id; + +-- Expectation: +-- - every detail has one stock log decrease from source and one stock log increase to destination + +-- [AFTER-04] Source rows after cutover +SELECT + kw.name AS source_warehouse_name, + p.name AS product_name, + COALESCE(pw.qty, 0) AS source_qty_after +FROM product_warehouses pw +JOIN warehouses kw ON kw.id = pw.warehouse_id +JOIN locations l ON l.id = kw.location_id +JOIN products p ON p.id = pw.product_id +WHERE LOWER(l.name) = LOWER('') + AND kw.type = 'KANDANG' + AND EXISTS ( + SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id + ) +ORDER BY kw.name, p.name; + +-- Expectation: +-- - rows that were transferred should now be 0 or no longer available for use + +-- [AFTER-05] Farm rows after cutover +SELECT + fw.name AS farm_warehouse_name, + p.name AS product_name, + COALESCE(pw.qty, 0) AS farm_qty_after +FROM product_warehouses pw +JOIN warehouses fw ON fw.id = pw.warehouse_id +JOIN locations l ON l.id = fw.location_id +JOIN products p ON p.id = pw.product_id +WHERE LOWER(l.name) = LOWER('') + AND fw.type = 'LOKASI' +ORDER BY fw.name, p.name; + +-- Expectation: +-- - farm qty increases by the moved amount + +-- [AFTER-06] Reconciliation: total moved by run +SELECT + p.name AS product_name, + SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty +FROM stock_transfers st +JOIN stock_transfer_details std + ON std.stock_transfer_id = st.id + AND std.deleted_at IS NULL +JOIN products p ON p.id = std.product_id +WHERE st.deleted_at IS NULL + AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=|%' +GROUP BY p.name +ORDER BY p.name; + +-- [AFTER-07] Farm stock available for SO after cutover +SELECT + fw.name AS farm_warehouse_name, + p.name AS product_name, + COALESCE(pw.qty, 0) AS available_qty +FROM product_warehouses pw +JOIN warehouses fw ON fw.id = pw.warehouse_id +JOIN locations l ON l.id = fw.location_id +JOIN products p ON p.id = pw.product_id +WHERE LOWER(l.name) = LOWER('') + AND fw.type = 'LOKASI' + AND COALESCE(pw.qty, 0) > 0 +ORDER BY p.name; + +-- ===================================================================== +-- ROLLBACK CHECKS +-- ===================================================================== + +-- [ROLLBACK-01] Check downstream consumption guard before rollback +SELECT + st.id AS transfer_id, + st.movement_number, + p.name AS product_name, + sa.usable_type, + sa.usable_id, + sa.qty, + sa.function_code, + sa.flag_group_code +FROM stock_transfers st +JOIN stock_transfer_details std + ON std.stock_transfer_id = st.id + AND std.deleted_at IS NULL +JOIN products p ON p.id = std.product_id +JOIN stock_allocations sa + ON sa.stockable_type = 'STOCK_TRANSFER_IN' + AND sa.stockable_id = std.id + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + AND sa.deleted_at IS NULL +WHERE st.deleted_at IS NULL + AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=|%' +ORDER BY st.id, p.name, sa.usable_type, sa.usable_id; + +-- Expectation: +-- - rollback only safe if this query returns 0 rows + +-- [ROLLBACK-02] Verify run is fully rolled back +SELECT + st.id, + st.movement_number, + st.deleted_at +FROM stock_transfers st +WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=|%' +ORDER BY st.id; + +-- Expectation: +-- - after rollback, deleted_at should be filled for all transfers in the run diff --git a/internal/modules/inventory/transfers/services/system_transfer.go b/internal/modules/inventory/transfers/services/system_transfer.go new file mode 100644 index 00000000..da83a639 --- /dev/null +++ b/internal/modules/inventory/transfers/services/system_transfer.go @@ -0,0 +1,548 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func (s *transferService) CreateSystemTransfer(ctx context.Context, req *SystemTransferRequest) (*entity.StockTransfer, error) { + if req == nil { + return nil, fmt.Errorf("system transfer request is required") + } + if strings.TrimSpace(req.TransferReason) == "" { + return nil, fmt.Errorf("transfer reason is required") + } + if req.TransferDate.IsZero() { + return nil, fmt.Errorf("transfer date is required") + } + if req.SourceWarehouseID == 0 || req.DestinationWarehouseID == 0 { + return nil, fmt.Errorf("source and destination warehouse are required") + } + if req.SourceWarehouseID == req.DestinationWarehouseID { + return nil, fmt.Errorf("source and destination warehouse must be different") + } + if req.ActorID == 0 { + return nil, fmt.Errorf("actor id is required") + } + + if err := s.validateTransferWarehousesAndProducts(ctx, req.SourceWarehouseID, req.DestinationWarehouseID, req.Products); err != nil { + return nil, err + } + + var result *entity.StockTransfer + err := s.StockTransferRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + movementResult, err := s.createTransferMovement(ctx, tx, req) + if err != nil { + return err + } + result = movementResult.Transfer + return nil + }) + if err != nil { + return nil, err + } + + return result, nil +} + +func (s *transferService) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error { + if id == 0 { + return fmt.Errorf("transfer id is required") + } + if actorID == 0 { + return fmt.Errorf("actor id is required") + } + + var deletedDetails []entity.StockTransferDetail + err := s.StockTransferRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var err error + deletedDetails, err = s.deleteTransferCore(ctx, tx, uint64(id), actorID) + return err + }) + if err != nil { + return err + } + + if len(deletedDetails) > 0 && s.ExpenseBridge != nil { + if err := s.ExpenseBridge.OnItemsDeleted(ctx, uint64(id), deletedDetails); err != nil { + s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense") + } + } + + return nil +} + +func (s *transferService) validateTransferWarehousesAndProducts( + ctx context.Context, + sourceWarehouseID uint, + destinationWarehouseID uint, + products []SystemTransferProduct, +) error { + if len(products) == 0 { + return fmt.Errorf("transfer products are required") + } + + pwIDs := make([]uint, 0, len(products)) + for _, product := range products { + if product.ProductID == 0 { + return fmt.Errorf("product id is required") + } + if product.ProductQty <= 0 { + return fmt.Errorf("product qty must be greater than 0 for product %d", product.ProductID) + } + + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + ctx, product.ProductID, sourceWarehouseID, + ) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, sourceWarehouseID)) + } + s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, sourceWarehouseID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk") + } + if sourcePW.Quantity < product.ProductQty { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty)) + } + pwIDs = append(pwIDs, sourcePW.Id) + } + + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(ctx, s.StockTransferRepo.DB(), pwIDs); err != nil { + return err + } + + destPfkID, err := s.getActiveProjectFlockKandangID(ctx, destinationWarehouseID) + if err != nil { + return err + } + if destPfkID == 0 { + return nil + } + + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(ctx, destPfkID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + if projectFlockKandang.ClosedAt != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02"))) + } + + return nil +} + +func (s *transferService) createTransferMovement( + ctx context.Context, + tx *gorm.DB, + req *SystemTransferRequest, +) (*transferMovementResult, error) { + if tx == nil { + return nil, fmt.Errorf("transaction is required") + } + + stockTransferRepoTX := s.StockTransferRepo.WithTx(tx) + stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx) + productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx) + stockLogsRepoTX := rStockLogs.NewStockLogRepository(tx) + + movementNumber := strings.TrimSpace(req.MovementNumber) + if movementNumber == "" { + var err error + movementNumber, err = s.StockTransferRepo.GenerateMovementNumber(ctx) + if err != nil { + s.Log.Errorf("Failed to generate movement number: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer") + } + } + + entityTransfer := &entity.StockTransfer{ + FromWarehouseId: uint64(req.SourceWarehouseID), + ToWarehouseId: uint64(req.DestinationWarehouseID), + Reason: req.TransferReason, + TransferDate: req.TransferDate, + MovementNumber: movementNumber, + CreatedBy: uint64(req.ActorID), + } + if err := stockTransferRepoTX.CreateOne(ctx, entityTransfer, nil); err != nil { + return nil, err + } + + details := make([]*entity.StockTransferDetail, 0, len(req.Products)) + detailMap := make(map[uint64]*entity.StockTransferDetail, len(req.Products)) + for _, product := range req.Products { + sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( + ctx, product.ProductID, req.SourceWarehouseID, + ) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) + } + s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal") + } + + destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( + ctx, product.ProductID, req.DestinationWarehouseID, + ) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan") + } + if errors.Is(err, gorm.ErrRecordNotFound) { + projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, req.DestinationWarehouseID) + if err != nil { + return nil, err + } + + var pfkID *uint + if projectFlockKandangID > 0 { + pfkID = &projectFlockKandangID + } + + destPW = &entity.ProductWarehouse{ + ProductId: product.ProductID, + WarehouseId: req.DestinationWarehouseID, + Quantity: 0, + ProjectFlockKandangId: pfkID, + } + if err := productWarehouseRepoTX.CreateOne(ctx, destPW, nil); err != nil { + s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan") + } + } + + detail := &entity.StockTransferDetail{ + StockTransferId: entityTransfer.Id, + ProductId: uint64(product.ProductID), + SourceProductWarehouseID: func() *uint64 { + id := uint64(sourcePW.Id) + return &id + }(), + UsageQty: 0, + PendingQty: 0, + DestProductWarehouseID: func() *uint64 { + id := uint64(destPW.Id) + return &id + }(), + TotalQty: 0, + TotalUsed: 0, + } + details = append(details, detail) + detailMap[uint64(product.ProductID)] = detail + } + + if err := stockTransferDetailRepoTX.CreateMany(ctx, details, nil); err != nil { + return nil, err + } + + flagGroupByProduct := make(map[uint]string, len(req.Products)) + for _, product := range req.Products { + detail := detailMap[uint64(product.ProductID)] + if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid") + } + + flagGroupCode, ok := flagGroupByProduct[product.ProductID] + if !ok { + var err error + flagGroupCode, err = s.resolveTransferFlagGroup(ctx, tx, product.ProductID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err)) + } + flagGroupByProduct[product.ProductID] = flagGroupCode + } + + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "usage_qty": product.ProductQty, + "pending_qty": 0, + "total_qty": product.ProductQty, + }).Error; err != nil { + s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") + } + + asOf := req.TransferDate + if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + AsOf: &asOf, + Tx: tx, + }); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) + } + if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: uint(*detail.DestProductWarehouseID), + AsOf: &asOf, + Tx: tx, + }); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err)) + } + + type usageSnapshot struct { + UsageQty float64 `gorm:"column:usage_qty"` + PendingQty float64 `gorm:"column:pending_qty"` + } + var usage usageSnapshot + if err := tx.WithContext(ctx). + Table("stock_transfer_details"). + Select("usage_qty, pending_qty"). + Where("id = ?", detail.Id). + Take(&usage).Error; err != nil { + s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking") + } + outUsageQty := usage.UsageQty + outPendingQty := usage.PendingQty + if outPendingQty > 1e-6 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID)) + } + + stockLogDecrease := &entity.StockLog{ + ProductWarehouseId: uint(*detail.SourceProductWarehouseID), + CreatedBy: req.ActorID, + Increase: 0, + Decrease: outUsageQty, + LoggableType: string(utils.StockLogTypeTransfer), + LoggableId: uint(detail.Id), + Notes: req.StockLogNotes, + } + stockLogs, err := stockLogsRepoTX.GetByProductWarehouse(ctx, uint(*detail.SourceProductWarehouseID), 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease + } else { + stockLogDecrease.Stock -= stockLogDecrease.Decrease + } + if err := stockLogsRepoTX.CreateOne(ctx, stockLogDecrease, nil); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") + } + + stockLogIncrease := &entity.StockLog{ + ProductWarehouseId: uint(*detail.DestProductWarehouseID), + CreatedBy: req.ActorID, + Increase: outUsageQty, + Decrease: 0, + LoggableType: string(utils.StockLogTypeTransfer), + LoggableId: uint(detail.Id), + Notes: req.StockLogNotes, + } + stockLogs, err = stockLogsRepoTX.GetByProductWarehouse(ctx, uint(*detail.DestProductWarehouseID), 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase + } else { + stockLogIncrease.Stock += stockLogIncrease.Increase + } + if err := stockLogsRepoTX.CreateOne(ctx, stockLogIncrease, nil); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") + } + } + + return &transferMovementResult{ + Transfer: entityTransfer, + DetailByPID: detailMap, + }, nil +} + +func (s *transferService) deleteTransferCore( + ctx context.Context, + tx *gorm.DB, + transferID uint64, + actorID uint, +) ([]entity.StockTransferDetail, error) { + stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) + + var transfer entity.StockTransfer + if err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", transferID). + Where("deleted_at IS NULL"). + Take(&transfer).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", transferID)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer") + } + + var details []entity.StockTransferDetail + if err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("stock_transfer_id = ?", transfer.Id). + Where("deleted_at IS NULL"). + Order("id ASC"). + Find(&details).Error; err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer") + } + if len(details) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk") + } + + detailIDs := make([]uint64, 0, len(details)) + for _, detail := range details { + detailIDs = append(detailIDs, detail.Id) + } + if err := s.ensureDeletePolicyForDownstreamConsumption(ctx, tx, detailIDs); err != nil { + return nil, err + } + + type reflowKey struct { + flagGroupCode string + productWarehouseID uint + } + destReflows := make(map[reflowKey]struct{}) + + for _, detail := range details { + if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id)) + } + if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id)) + } + + flagGroupCode, err := s.resolveTransferFlagGroup(ctx, tx, uint(detail.ProductId)) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err)) + } + + rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, commonSvc.FifoStockV2RollbackRequest{ + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + Usable: commonSvc.FifoStockV2Ref{ + ID: uint(detail.Id), + LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(), + FunctionCode: "STOCK_TRANSFER_OUT", + }, + Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber), + Tx: tx, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err)) + } + + releasedQty := 0.0 + if rollbackRes != nil { + releasedQty = rollbackRes.ReleasedQty + } + if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty), + ) + } + + if releasedQty > 1e-6 { + if err := s.appendStockLog( + ctx, + stockLogRepoTx, + uint(*detail.SourceProductWarehouseID), + actorID, + releasedQty, + 0, + uint(detail.Id), + fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber), + ); err != nil { + return nil, err + } + } + + destDecreaseQty := detail.TotalQty + if destDecreaseQty <= 1e-6 { + destDecreaseQty = detail.UsageQty + } + if destDecreaseQty > 1e-6 { + if err := s.appendStockLog( + ctx, + stockLogRepoTx, + uint(*detail.DestProductWarehouseID), + actorID, + 0, + destDecreaseQty, + uint(detail.Id), + fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber), + ); err != nil { + return nil, err + } + } + + destReflows[reflowKey{ + flagGroupCode: flagGroupCode, + productWarehouseID: uint(*detail.DestProductWarehouseID), + }] = struct{}{} + } + + now := time.Now().UTC() + if err := tx.WithContext(ctx). + Where("stock_transfer_detail_id IN ?", detailIDs). + Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer") + } + if err := tx.WithContext(ctx). + Model(&entity.StockTransferDelivery{}). + Where("stock_transfer_id = ?", transfer.Id). + Where("deleted_at IS NULL"). + Updates(map[string]any{ + "deleted_at": now, + "updated_at": now, + }).Error; err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer") + } + if err := tx.WithContext(ctx). + Model(&entity.StockTransferDetail{}). + Where("id IN ?", detailIDs). + Where("deleted_at IS NULL"). + Updates(map[string]any{ + "deleted_at": now, + "updated_at": now, + }).Error; err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer") + } + + asOf := transfer.TransferDate + for key := range destReflows { + if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: key.flagGroupCode, + ProductWarehouseID: key.productWarehouseID, + AsOf: &asOf, + Tx: tx, + }); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err)) + } + } + + if err := tx.WithContext(ctx). + Model(&entity.StockTransfer{}). + Where("id = ?", transfer.Id). + Where("deleted_at IS NULL"). + Updates(map[string]any{ + "deleted_at": now, + "updated_at": now, + }).Error; err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer") + } + + return details, nil +} diff --git a/internal/modules/inventory/transfers/services/system_transfer_test.go b/internal/modules/inventory/transfers/services/system_transfer_test.go new file mode 100644 index 00000000..f3d12742 --- /dev/null +++ b/internal/modules/inventory/transfers/services/system_transfer_test.go @@ -0,0 +1,481 @@ +package service + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/go-playground/validator/v10" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +func TestCreateSystemTransferCreatesAuditableMovement(t *testing.T) { + db := setupSystemTransferTestDB(t) + svc, fifoStub := newSystemTransferTestService(t, db) + + transferDate := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC) + result, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{ + TransferReason: "EGG_FARM_CUTOVER|run_id=test-1|location=Jamali|cutover_date=2026-04-07", + TransferDate: transferDate, + SourceWarehouseID: 1, + DestinationWarehouseID: 2, + Products: []SystemTransferProduct{ + {ProductID: 8, ProductQty: 50}, + }, + ActorID: 99, + MovementNumber: "PND-LTI-TEST-0001", + StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-1|location=Jamali|cutover_date=2026-04-07", + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected transfer result") + } + if result.MovementNumber != "PND-LTI-TEST-0001" { + t.Fatalf("expected movement number to be preserved, got %s", result.MovementNumber) + } + + var transfer entity.StockTransfer + if err := db.WithContext(context.Background()).First(&transfer, result.Id).Error; err != nil { + t.Fatalf("failed to load created transfer: %v", err) + } + + var detail entity.StockTransferDetail + if err := db.WithContext(context.Background()). + Where("stock_transfer_id = ?", transfer.Id). + First(&detail).Error; err != nil { + t.Fatalf("failed to load transfer detail: %v", err) + } + if detail.UsageQty != 50 { + t.Fatalf("expected usage qty 50, got %v", detail.UsageQty) + } + if detail.TotalQty != 50 { + t.Fatalf("expected total qty 50, got %v", detail.TotalQty) + } + if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID != 10 { + t.Fatalf("expected source product warehouse 10, got %+v", detail.SourceProductWarehouseID) + } + if detail.DestProductWarehouseID == nil { + t.Fatal("expected destination product warehouse to be created") + } + + var destPW entity.ProductWarehouse + if err := db.WithContext(context.Background()). + First(&destPW, *detail.DestProductWarehouseID).Error; err != nil { + t.Fatalf("failed to load destination product warehouse: %v", err) + } + if destPW.WarehouseId != 2 { + t.Fatalf("expected destination warehouse id 2, got %d", destPW.WarehouseId) + } + if destPW.ProductId != 8 { + t.Fatalf("expected destination product id 8, got %d", destPW.ProductId) + } + if destPW.ProjectFlockKandangId != nil { + t.Fatalf("expected destination product warehouse to stay shared, got %+v", destPW.ProjectFlockKandangId) + } + + var stockLogs []entity.StockLog + if err := db.WithContext(context.Background()). + Order("id ASC"). + Find(&stockLogs).Error; err != nil { + t.Fatalf("failed to load stock logs: %v", err) + } + if len(stockLogs) != 3 { + t.Fatalf("expected 3 stock logs (seed + out + in), got %d", len(stockLogs)) + } + if stockLogs[1].ProductWarehouseId != 10 || stockLogs[1].Decrease != 50 || stockLogs[1].Stock != 0 { + t.Fatalf("unexpected source stock log after transfer: %+v", stockLogs[1]) + } + if stockLogs[2].ProductWarehouseId != destPW.Id || stockLogs[2].Increase != 50 || stockLogs[2].Stock != 50 { + t.Fatalf("unexpected destination stock log after transfer: %+v", stockLogs[2]) + } + + if len(fifoStub.reflowCalls) != 2 { + t.Fatalf("expected 2 reflow calls, got %d", len(fifoStub.reflowCalls)) + } + if fifoStub.reflowCalls[0].ProductWarehouseID != 10 { + t.Fatalf("expected first reflow on source pw 10, got %d", fifoStub.reflowCalls[0].ProductWarehouseID) + } + if fifoStub.reflowCalls[1].ProductWarehouseID != destPW.Id { + t.Fatalf("expected second reflow on destination pw %d, got %d", destPW.Id, fifoStub.reflowCalls[1].ProductWarehouseID) + } +} + +func TestDeleteSystemTransferRollsBackTransferWhenUnused(t *testing.T) { + db := setupSystemTransferTestDB(t) + svc, fifoStub := newSystemTransferTestService(t, db) + + created, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{ + TransferReason: "EGG_FARM_CUTOVER|run_id=test-rollback|location=Jamali|cutover_date=2026-04-07", + TransferDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC), + SourceWarehouseID: 1, + DestinationWarehouseID: 2, + Products: []SystemTransferProduct{{ProductID: 8, ProductQty: 50}}, + ActorID: 99, + MovementNumber: "PND-LTI-TEST-ROLLBACK", + StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-rollback|location=Jamali|cutover_date=2026-04-07", + }) + if err != nil { + t.Fatalf("failed to create transfer: %v", err) + } + + var detail entity.StockTransferDetail + if err := db.WithContext(context.Background()). + Where("stock_transfer_id = ?", created.Id). + First(&detail).Error; err != nil { + t.Fatalf("failed to load transfer detail: %v", err) + } + fifoStub.rollbackReleasedQty[detail.Id] = detail.UsageQty + + if err := svc.DeleteSystemTransfer(context.Background(), uint(created.Id), 99); err != nil { + t.Fatalf("expected delete to succeed, got %v", err) + } + + var deletedTransfer entity.StockTransfer + if err := db.WithContext(context.Background()).Unscoped().First(&deletedTransfer, created.Id).Error; err != nil { + t.Fatalf("failed to load deleted transfer: %v", err) + } + if deletedTransfer.DeletedAt == nil { + t.Fatal("expected transfer to be soft deleted") + } + + var deletedDetail entity.StockTransferDetail + if err := db.WithContext(context.Background()).Unscoped().First(&deletedDetail, detail.Id).Error; err != nil { + t.Fatalf("failed to load deleted transfer detail: %v", err) + } + if deletedDetail.DeletedAt == nil { + t.Fatal("expected transfer detail to be soft deleted") + } + + var stockLogs []entity.StockLog + if err := db.WithContext(context.Background()). + Order("id ASC"). + Find(&stockLogs).Error; err != nil { + t.Fatalf("failed to load stock logs: %v", err) + } + if len(stockLogs) != 5 { + t.Fatalf("expected 5 stock logs (seed + create out/in + delete in/out), got %d", len(stockLogs)) + } + if stockLogs[3].ProductWarehouseId != 10 || stockLogs[3].Increase != 50 || stockLogs[3].Stock != 50 { + t.Fatalf("unexpected rollback source stock log: %+v", stockLogs[3]) + } + if stockLogs[4].Decrease != 50 || stockLogs[4].Stock != 0 { + t.Fatalf("unexpected rollback destination stock log: %+v", stockLogs[4]) + } + if len(fifoStub.rollbackCalls) != 1 { + t.Fatalf("expected 1 rollback call, got %d", len(fifoStub.rollbackCalls)) + } + if len(fifoStub.reflowCalls) != 3 { + t.Fatalf("expected 3 reflow calls (2 create + 1 delete), got %d", len(fifoStub.reflowCalls)) + } +} + +func TestDeleteSystemTransferRejectsRollbackWhenDownstreamConsumptionExists(t *testing.T) { + db := setupSystemTransferTestDB(t) + svc, fifoStub := newSystemTransferTestService(t, db) + + created, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{ + TransferReason: "EGG_FARM_CUTOVER|run_id=test-guard|location=Jamali|cutover_date=2026-04-07", + TransferDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC), + SourceWarehouseID: 1, + DestinationWarehouseID: 2, + Products: []SystemTransferProduct{{ProductID: 8, ProductQty: 50}}, + ActorID: 99, + MovementNumber: "PND-LTI-TEST-GUARD", + StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-guard|location=Jamali|cutover_date=2026-04-07", + }) + if err != nil { + t.Fatalf("failed to create transfer: %v", err) + } + + var detail entity.StockTransferDetail + if err := db.WithContext(context.Background()). + Where("stock_transfer_id = ?", created.Id). + First(&detail).Error; err != nil { + t.Fatalf("failed to load transfer detail: %v", err) + } + + if err := db.Exec(` + INSERT INTO stock_allocations ( + id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, + allocation_purpose, status, function_code, flag_group_code, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) + `, 1, *detail.DestProductWarehouseID, fifo.StockableKeyStockTransferIn.String(), detail.Id, fifo.UsableKeyRecordingStock.String(), 9001, 10, + entity.StockAllocationPurposeConsume, entity.StockAllocationStatusActive, "RECORDING_STOCK_OUT", "EGG").Error; err != nil { + t.Fatalf("failed to seed stock allocation: %v", err) + } + + err = svc.DeleteSystemTransfer(context.Background(), uint(created.Id), 99) + if err == nil { + t.Fatal("expected delete to be blocked by downstream consumption") + } + if !strings.Contains(err.Error(), "tidak dapat dihapus") { + t.Fatalf("expected downstream guard error, got %v", err) + } + if len(fifoStub.rollbackCalls) != 0 { + t.Fatalf("expected rollback not to be called, got %d calls", len(fifoStub.rollbackCalls)) + } + + var transfer entity.StockTransfer + if err := db.WithContext(context.Background()).First(&transfer, created.Id).Error; err != nil { + t.Fatalf("failed to reload transfer: %v", err) + } + if transfer.DeletedAt != nil { + t.Fatal("expected transfer to remain active after guard failure") + } +} + +type fifoStockV2Stub struct { + reflowCalls []commonSvc.FifoStockV2ReflowRequest + rollbackCalls []commonSvc.FifoStockV2RollbackRequest + rollbackReleasedQty map[uint64]float64 +} + +func (f *fifoStockV2Stub) Gather(ctx context.Context, req commonSvc.FifoStockV2GatherRequest) ([]commonSvc.FifoStockV2GatherRow, error) { + return nil, nil +} + +func (f *fifoStockV2Stub) Allocate(ctx context.Context, req commonSvc.FifoStockV2AllocateRequest) (*commonSvc.FifoStockV2AllocateResult, error) { + return nil, nil +} + +func (f *fifoStockV2Stub) Rollback(ctx context.Context, req commonSvc.FifoStockV2RollbackRequest) (*commonSvc.FifoStockV2RollbackResult, error) { + f.rollbackCalls = append(f.rollbackCalls, req) + return &commonSvc.FifoStockV2RollbackResult{ + ReleasedQty: f.rollbackReleasedQty[uint64(req.Usable.ID)], + }, nil +} + +func (f *fifoStockV2Stub) Reflow(ctx context.Context, req commonSvc.FifoStockV2ReflowRequest) (*commonSvc.FifoStockV2ReflowResult, error) { + f.reflowCalls = append(f.reflowCalls, req) + return &commonSvc.FifoStockV2ReflowResult{}, nil +} + +func (f *fifoStockV2Stub) Recalculate(ctx context.Context, req commonSvc.FifoStockV2RecalculateRequest) (*commonSvc.FifoStockV2RecalculateResult, error) { + return &commonSvc.FifoStockV2RecalculateResult{}, nil +} + +func newSystemTransferTestService(t *testing.T, db *gorm.DB) (TransferService, *fifoStockV2Stub) { + t.Helper() + + fifoStub := &fifoStockV2Stub{rollbackReleasedQty: make(map[uint64]float64)} + return NewTransferService( + validator.New(), + rTransfer.NewStockTransferRepository(db), + rTransfer.NewStockTransferDetailRepository(db), + rTransfer.NewStockTransferDeliveryRepository(db), + rTransfer.NewStockTransferDeliveryItemRepository(db), + rStockLogs.NewStockLogRepository(db), + rProductWarehouse.NewProductWarehouseRepository(db), + nil, + rWarehouse.NewWarehouseRepository(db), + nil, + nil, + nil, + fifoStub, + nil, + ), fifoStub +} + +func setupSystemTransferTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + statements := []string{ + `CREATE TABLE warehouses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + area_id INTEGER NOT NULL DEFAULT 1, + location_id INTEGER NULL, + kandang_id INTEGER NULL, + created_by INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE product_categories ( + id INTEGER PRIMARY KEY, + name TEXT NULL, + code TEXT NOT NULL, + created_by INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE products ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + brand TEXT NOT NULL DEFAULT '', + sku TEXT NULL, + uom_id INTEGER NOT NULL DEFAULT 1, + product_category_id INTEGER NULL, + product_price NUMERIC NOT NULL DEFAULT 0, + selling_price NUMERIC NULL, + tax NUMERIC NULL, + expiry_period INTEGER NULL, + created_by INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + is_visible BOOLEAN NOT NULL DEFAULT 1 + )`, + `CREATE TABLE product_warehouses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL, + warehouse_id INTEGER NOT NULL, + project_flock_kandang_id INTEGER NULL, + qty NUMERIC NOT NULL DEFAULT 0 + )`, + `CREATE TABLE flags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + flagable_id INTEGER NOT NULL, + flagable_type TEXT NOT NULL + )`, + `CREATE TABLE fifo_stock_v2_flag_groups ( + code TEXT PRIMARY KEY, + is_active BOOLEAN NOT NULL + )`, + `CREATE TABLE fifo_stock_v2_flag_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + flag_name TEXT NOT NULL, + flag_group_code TEXT NOT NULL, + is_active BOOLEAN NOT NULL + )`, + `CREATE TABLE fifo_stock_v2_route_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lane TEXT NOT NULL, + function_code TEXT NOT NULL, + source_table TEXT NOT NULL, + flag_group_code TEXT NOT NULL, + legacy_type_key TEXT NULL, + allow_pending_default BOOLEAN NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL + )`, + `CREATE TABLE fifo_stock_v2_overconsume_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lane TEXT NOT NULL, + flag_group_code TEXT NULL, + function_code TEXT NULL, + allow_overconsume BOOLEAN NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT 1, + priority INTEGER NOT NULL DEFAULT 1 + )`, + `CREATE TABLE stock_transfers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + movement_number TEXT NOT NULL, + from_warehouse_id INTEGER NOT NULL, + to_warehouse_id INTEGER NOT NULL, + transfer_date TIMESTAMP NOT NULL, + reason TEXT, + created_by INTEGER NOT NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE stock_transfer_details ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_transfer_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + source_product_warehouse_id INTEGER NULL, + usage_qty NUMERIC NOT NULL DEFAULT 0, + pending_qty NUMERIC NOT NULL DEFAULT 0, + dest_product_warehouse_id INTEGER NULL, + total_qty NUMERIC NOT NULL DEFAULT 0, + total_used NUMERIC NOT NULL DEFAULT 0, + expense_nonstock_id INTEGER NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE stock_transfer_deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_transfer_id INTEGER NOT NULL, + supplier_id INTEGER NULL, + vehicle_plate TEXT NULL, + driver_name TEXT NULL, + shipping_cost_item NUMERIC NULL, + shipping_cost_total NUMERIC NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE stock_transfer_delivery_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_transfer_delivery_id INTEGER NOT NULL, + stock_transfer_detail_id INTEGER NOT NULL, + quantity NUMERIC NOT NULL DEFAULT 0, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE stock_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_warehouse_id INTEGER NOT NULL, + created_by INTEGER NOT NULL, + increase NUMERIC NOT NULL DEFAULT 0, + decrease NUMERIC NOT NULL DEFAULT 0, + stock NUMERIC NOT NULL DEFAULT 0, + loggable_type TEXT NOT NULL, + loggable_id INTEGER NOT NULL, + notes TEXT NULL, + created_at TIMESTAMP NULL + )`, + `CREATE TABLE stock_allocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_warehouse_id INTEGER NOT NULL, + stockable_type TEXT NOT NULL, + stockable_id INTEGER NOT NULL, + usable_type TEXT NOT NULL, + usable_id INTEGER NOT NULL, + qty NUMERIC NOT NULL DEFAULT 0, + allocation_purpose TEXT NOT NULL, + status TEXT NOT NULL, + function_code TEXT NULL, + flag_group_code TEXT NULL, + deleted_at TIMESTAMP NULL + )`, + `INSERT INTO warehouses (id, name, type, area_id, location_id, kandang_id, created_by, created_at, updated_at, deleted_at) VALUES + (1, 'Gudang Kandang Legacy', 'LOKASI', 1, 16, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), + (2, 'Gudang Farm Jamali', 'LOKASI', 1, 16, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, + `INSERT INTO product_categories (id, name, code, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Egg', 'EGG', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, + `INSERT INTO products ( + id, name, brand, sku, uom_id, product_category_id, product_price, selling_price, tax, + expiry_period, created_by, created_at, updated_at, deleted_at, is_visible + ) VALUES ( + 8, 'Telur Utuh', '', NULL, 1, 1, 0, NULL, NULL, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, 1 + )`, + `INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES + (10, 8, 1, NULL, 50)`, + `INSERT INTO flags (name, flagable_id, flagable_type) VALUES ('TELUR', 8, 'products')`, + `INSERT INTO fifo_stock_v2_flag_groups (code, is_active) VALUES ('EGG', 1)`, + `INSERT INTO fifo_stock_v2_flag_members (flag_name, flag_group_code, is_active) VALUES ('TELUR', 'EGG', 1)`, + `INSERT INTO fifo_stock_v2_route_rules (lane, function_code, source_table, flag_group_code, legacy_type_key, allow_pending_default, is_active) VALUES + ('USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'EGG', 'STOCK_TRANSFER_OUT', 0, 1)`, + `INSERT INTO stock_logs (id, product_warehouse_id, created_by, increase, decrease, stock, loggable_type, loggable_id, notes, created_at) VALUES + (1, 10, 1, 50, 0, 50, 'PURCHASE', 1, 'seed', CURRENT_TIMESTAMP)`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing test schema: %v\nstatement: %s", err, stmt) + } + } + + return db +} diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 8d4ccdfe..9294a9fd 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -26,7 +26,6 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" - "gorm.io/gorm/clause" ) type TransferService interface { @@ -34,6 +33,8 @@ type TransferService interface { GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) DeleteOne(ctx *fiber.Ctx, id uint) error + CreateSystemTransfer(ctx context.Context, req *SystemTransferRequest) (*entity.StockTransfer, error) + DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error } type transferService struct { @@ -63,6 +64,27 @@ type downstreamDependency struct { FlagGroupCode string `gorm:"column:flag_group_code"` } +type SystemTransferProduct struct { + ProductID uint + ProductQty float64 +} + +type SystemTransferRequest struct { + TransferReason string + TransferDate time.Time + SourceWarehouseID uint + DestinationWarehouseID uint + Products []SystemTransferProduct + ActorID uint + MovementNumber string + StockLogNotes string +} + +type transferMovementResult struct { + Transfer *entity.StockTransfer + DetailByPID map[uint64]*entity.StockTransferDetail +} + func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService { return &transferService{ Log: utils.Log, @@ -185,50 +207,17 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e } func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { - - pwIDs := make([]uint, 0, len(req.Products)) - + products := make([]SystemTransferProduct, 0, len(req.Products)) for _, product := range req.Products { - sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( - c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), - ) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) - } - s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk") - } - if sourcePW.Quantity < product.ProductQty { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty)) - } - pwIDs = append(pwIDs, sourcePW.Id) + products = append(products, SystemTransferProduct{ + ProductID: uint(product.ProductID), + ProductQty: product.ProductQty, + }) } - - if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( - c.Context(), - s.StockTransferRepo.DB(), - pwIDs, - ); err != nil { + if err := s.validateTransferWarehousesAndProducts(c.Context(), uint(req.SourceWarehouseID), uint(req.DestinationWarehouseID), products); err != nil { return nil, err } - destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID)) - if err != nil { - return nil, err - } - - if destPfkID > 0 { - projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) - if err != nil { - s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") - } - if projectFlockKandang.ClosedAt != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02"))) - } - } - actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -249,11 +238,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } for _, delivery := range req.Deliveries { - if delivery.SupplierID == 0 { continue } - if delivery.VehiclePlate == "" { return nil, fiber.NewError(fiber.StatusBadRequest, "Vehicle plate wajib diisi ketika supplier dipilih") } @@ -280,104 +267,28 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } - movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context()) - if err != nil { - s.Log.Errorf("Failed to generate movement number: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer") - } - transferDate, _ := utils.ParseDateString(req.TransferDate) - - entityTransfer := &entity.StockTransfer{ - FromWarehouseId: uint64(req.SourceWarehouseID), - ToWarehouseId: uint64(req.DestinationWarehouseID), - Reason: req.TransferReason, - TransferDate: transferDate, - MovementNumber: movementNumber, - CreatedBy: uint64(actorID), - } - expensePayloads := make([]TransferExpenseReceivingPayload, 0) + var detailMap map[uint64]*entity.StockTransferDetail + var createdTransfer *entity.StockTransfer err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { - - stockTransferRepoTX := s.StockTransferRepo.WithTx(tx) - stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx) stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx) stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx) - productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx) - stocklogsRepoTx := s.StockLogsRepository.WithTx(tx) - if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil { - return err - } - - details := make([]*entity.StockTransferDetail, 0, len(req.Products)) - detailMap := make(map[uint64]*entity.StockTransferDetail) - - for _, product := range req.Products { - - sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( - c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), - ) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) - } - s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal") - } - - destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( - c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), - ) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan") - } - if errors.Is(err, gorm.ErrRecordNotFound) { - ctx := c.Context() - projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) - if err != nil { - return err - } - - var pfkID *uint - if projectFlockKandangID > 0 { - pfkID = &projectFlockKandangID - } - - destPW = &entity.ProductWarehouse{ - ProductId: uint(product.ProductID), - WarehouseId: uint(req.DestinationWarehouseID), - Quantity: 0, - ProjectFlockKandangId: pfkID, - } - if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { - s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan") - } - } - - detail := &entity.StockTransferDetail{ - StockTransferId: entityTransfer.Id, - ProductId: uint64(product.ProductID), - - SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(), - UsageQty: 0, - PendingQty: 0, - - DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(), - TotalQty: 0, - TotalUsed: 0, - } - details = append(details, detail) - detailMap[uint64(product.ProductID)] = detail - } - - if err := stockTransferDetailRepoTX.CreateMany(c.Context(), details, nil); err != nil { + movementResult, err := s.createTransferMovement(c.Context(), tx, &SystemTransferRequest{ + TransferReason: req.TransferReason, + TransferDate: transferDate, + SourceWarehouseID: uint(req.SourceWarehouseID), + DestinationWarehouseID: uint(req.DestinationWarehouseID), + Products: products, + ActorID: actorID, + }) + if err != nil { return err } + detailMap = movementResult.DetailByPID + createdTransfer = movementResult.Transfer var deliveries []*entity.StockTransferDelivery for _, delivery := range req.Deliveries { @@ -389,7 +300,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil }() deliveries = append(deliveries, &entity.StockTransferDelivery{ - StockTransferId: entityTransfer.Id, + StockTransferId: createdTransfer.Id, SupplierId: supplierId, VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, @@ -402,7 +313,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } var deliveryItems []*entity.StockTransferDeliveryItem - for i, delivery := range deliveries { item := req.Deliveries[i] for _, prod := range item.Products { @@ -422,14 +332,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } if s.DocumentSvc != nil && len(files) > 0 { - for deliveryIdx, delivery := range deliveries { reqDelivery := req.Deliveries[deliveryIdx] - if reqDelivery.DocumentIndex < 0 { continue } - if reqDelivery.DocumentIndex >= len(files) { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("DocumentIndex %d untuk delivery %d melebihi jumlah file yang diupload (%d)", @@ -437,14 +344,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } file := files[reqDelivery.DocumentIndex] - - documentFiles := []commonSvc.DocumentFile{ - { - File: file, - Type: string(utils.DocumentTypeTransfer), - Index: &reqDelivery.DocumentIndex, - }, - } + documentFiles := []commonSvc.DocumentFile{{ + File: file, + Type: string(utils.DocumentTypeTransfer), + Index: &reqDelivery.DocumentIndex, + }} _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ DocumentableType: string(utils.DocumentableTypeTransfer), DocumentableID: delivery.Id, @@ -459,160 +363,31 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } - if s.FifoStockV2Svc == nil { - return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") - } - flagGroupByProduct := make(map[uint]string, len(req.Products)) - - for _, product := range req.Products { - detail := detailMap[uint64(product.ProductID)] - if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil { - return fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid") + for _, delivery := range req.Deliveries { + if delivery.SupplierID == 0 { + continue } - - flagGroupCode, ok := flagGroupByProduct[uint(product.ProductID)] - if !ok { - flagGroupCode, err = s.resolveTransferFlagGroup(c.Context(), tx, uint(product.ProductID)) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err)) - } - flagGroupByProduct[uint(product.ProductID)] = flagGroupCode - } - - if err := tx.Model(&entity.StockTransferDetail{}). - Where("id = ?", detail.Id). - Updates(map[string]interface{}{ - "usage_qty": product.ProductQty, - "pending_qty": 0, - "total_qty": product.ProductQty, - }).Error; err != nil { - s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") - } - - asOf := transferDate - if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ - FlagGroupCode: flagGroupCode, - ProductWarehouseID: uint(*detail.SourceProductWarehouseID), - AsOf: &asOf, - Tx: tx, - }); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) - } - if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ - FlagGroupCode: flagGroupCode, - ProductWarehouseID: uint(*detail.DestProductWarehouseID), - AsOf: &asOf, - Tx: tx, - }); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err)) - } - - type usageSnapshot struct { - UsageQty float64 `gorm:"column:usage_qty"` - PendingQty float64 `gorm:"column:pending_qty"` - } - var usage usageSnapshot - if err := tx.WithContext(c.Context()). - Table("stock_transfer_details"). - Select("usage_qty, pending_qty"). - Where("id = ?", detail.Id). - Take(&usage).Error; err != nil { - s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking") - } - outUsageQty := usage.UsageQty - outPendingQty := usage.PendingQty - if outPendingQty > 1e-6 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID)) - } - - stockLogDecrease := &entity.StockLog{ - ProductWarehouseId: uint(*detail.SourceProductWarehouseID), - CreatedBy: uint(actorID), - Increase: 0, - Decrease: outUsageQty, - LoggableType: string(utils.StockLogTypeTransfer), - LoggableId: uint(detail.Id), - Notes: "", - } - stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 1) - if err != nil { - s.Log.Errorf("Failed to get stock logs: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease - } else { - stockLogDecrease.Stock -= stockLogDecrease.Decrease - } - - if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") - } - - inAddedQty := outUsageQty - - stockLogIncrease := &entity.StockLog{ - ProductWarehouseId: uint(*detail.DestProductWarehouseID), - CreatedBy: uint(actorID), - Increase: inAddedQty, - Decrease: 0, - LoggableType: string(utils.StockLogTypeTransfer), - LoggableId: uint(detail.Id), - Notes: "", - } - stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 1) - if err != nil { - s.Log.Errorf("Failed to get stock logs: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase - } else { - stockLogIncrease.Stock += stockLogIncrease.Increase - } - if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") - } - } - - if len(req.Deliveries) > 0 { - for _, delivery := range req.Deliveries { - // Skip adding to expensePayloads if SupplierID is 0 (optional) - if delivery.SupplierID == 0 { + for _, prod := range delivery.Products { + detail := detailMap[uint64(prod.ProductID)] + if detail == nil { continue } - - for _, prod := range delivery.Products { - detail := detailMap[uint64(prod.ProductID)] - if detail == nil { - continue - } - - warehouseID := uint(req.DestinationWarehouseID) - supplierID := uint(delivery.SupplierID) - deliveredDate := transferDate - deliveredQty := prod.ProductQty - - payload := TransferExpenseReceivingPayload{ - TransferDetailID: detail.Id, - ProductID: uint64(prod.ProductID), - WarehouseID: uint64(warehouseID), - SupplierID: uint64(supplierID), - DeliveredQty: deliveredQty, - DeliveredDate: &deliveredDate, - } - expensePayloads = append(expensePayloads, payload) - } + warehouseID := uint(req.DestinationWarehouseID) + supplierID := uint(delivery.SupplierID) + deliveredDate := transferDate + expensePayloads = append(expensePayloads, TransferExpenseReceivingPayload{ + TransferDetailID: detail.Id, + ProductID: uint64(prod.ProductID), + WarehouseID: uint64(warehouseID), + SupplierID: uint64(supplierID), + DeliveredQty: prod.ProductQty, + DeliveredDate: &deliveredDate, + }) } } return nil }) - if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { return nil, fiberErr @@ -620,14 +395,13 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal server error") } - result, err := s.GetOne(c, uint(entityTransfer.Id)) + result, err := s.GetOne(c, uint(createdTransfer.Id)) if err != nil { return nil, err } - if len(expensePayloads) > 0 { - if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil { - s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", entityTransfer.Id, entityTransfer.MovementNumber, err) + if err := s.notifyExpenseItemsDelivered(c, createdTransfer.Id, expensePayloads); err != nil { + s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", createdTransfer.Id, createdTransfer.MovementNumber, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense") } } @@ -650,177 +424,9 @@ func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error { var deletedDetails []entity.StockTransferDetail err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { - stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) - - var transfer entity.StockTransfer - if err := tx.WithContext(c.Context()). - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("id = ?", uint64(id)). - Where("deleted_at IS NULL"). - Take(&transfer).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer") - } - - var details []entity.StockTransferDetail - if err := tx.WithContext(c.Context()). - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("stock_transfer_id = ?", transfer.Id). - Where("deleted_at IS NULL"). - Order("id ASC"). - Find(&details).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer") - } - if len(details) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk") - } - - detailIDs := make([]uint64, 0, len(details)) - for _, detail := range details { - detailIDs = append(detailIDs, detail.Id) - } - if err := s.ensureDeletePolicyForDownstreamConsumption(c.Context(), tx, detailIDs); err != nil { - return err - } - - type reflowKey struct { - flagGroupCode string - productWarehouseID uint - } - destReflows := make(map[reflowKey]struct{}) - - for _, detail := range details { - if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id)) - } - if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id)) - } - - flagGroupCode, err := s.resolveTransferFlagGroup(c.Context(), tx, uint(detail.ProductId)) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err)) - } - - rollbackRes, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{ - ProductWarehouseID: uint(*detail.SourceProductWarehouseID), - Usable: commonSvc.FifoStockV2Ref{ - ID: uint(detail.Id), - LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(), - FunctionCode: "STOCK_TRANSFER_OUT", - }, - Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber), - Tx: tx, - }) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err)) - } - - releasedQty := 0.0 - if rollbackRes != nil { - releasedQty = rollbackRes.ReleasedQty - } - if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty), - ) - } - - if releasedQty > 1e-6 { - if err := s.appendStockLog( - c.Context(), - stockLogRepoTx, - uint(*detail.SourceProductWarehouseID), - actorID, - releasedQty, - 0, - uint(detail.Id), - fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber), - ); err != nil { - return err - } - } - - destDecreaseQty := detail.TotalQty - if destDecreaseQty <= 1e-6 { - destDecreaseQty = detail.UsageQty - } - if destDecreaseQty > 1e-6 { - if err := s.appendStockLog( - c.Context(), - stockLogRepoTx, - uint(*detail.DestProductWarehouseID), - actorID, - 0, - destDecreaseQty, - uint(detail.Id), - fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber), - ); err != nil { - return err - } - } - - destReflows[reflowKey{ - flagGroupCode: flagGroupCode, - productWarehouseID: uint(*detail.DestProductWarehouseID), - }] = struct{}{} - } - - now := time.Now().UTC() - if err := tx.WithContext(c.Context()). - Where("stock_transfer_detail_id IN ?", detailIDs). - Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer") - } - if err := tx.WithContext(c.Context()). - Model(&entity.StockTransferDelivery{}). - Where("stock_transfer_id = ?", transfer.Id). - Where("deleted_at IS NULL"). - Updates(map[string]any{ - "deleted_at": now, - "updated_at": now, - }).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer") - } - if err := tx.WithContext(c.Context()). - Model(&entity.StockTransferDetail{}). - Where("id IN ?", detailIDs). - Where("deleted_at IS NULL"). - Updates(map[string]any{ - "deleted_at": now, - "updated_at": now, - }).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer") - } - - asOf := transfer.TransferDate - for key := range destReflows { - if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ - FlagGroupCode: key.flagGroupCode, - ProductWarehouseID: key.productWarehouseID, - AsOf: &asOf, - Tx: tx, - }); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err)) - } - } - - if err := tx.WithContext(c.Context()). - Model(&entity.StockTransfer{}). - Where("id = ?", transfer.Id). - Where("deleted_at IS NULL"). - Updates(map[string]any{ - "deleted_at": now, - "updated_at": now, - }).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer") - } - - deletedDetails = append(deletedDetails, details...) - return nil + var err error + deletedDetails, err = s.deleteTransferCore(c.Context(), tx, uint64(id), actorID) + return err }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { @@ -863,13 +469,31 @@ func (s *transferService) resolveTransferFlagGroup( Where(` 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 = ? - AND fm.flag_group_code = rr.flag_group_code + 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' + ) + ) ) - `, entity.FlagableTypeProduct, productID). + `, productID, entity.FlagableTypeProduct, entity.FlagableTypeProduct, utils.LegacyFlagGroupCodeByProductCategoryCode("EGG")). Order("rr.id ASC"). Limit(1). Take(&selected).Error