diff --git a/auto-transfer-products-to-farm b/auto-transfer-products-to-farm new file mode 100755 index 00000000..9256ccd8 Binary files /dev/null and b/auto-transfer-products-to-farm differ diff --git a/cmd/auto-transfer-products-to-farm/main.go b/cmd/auto-transfer-products-to-farm/main.go new file mode 100644 index 00000000..221b5ad3 --- /dev/null +++ b/cmd/auto-transfer-products-to-farm/main.go @@ -0,0 +1,870 @@ +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 ( + transferReasonPrefix = "PRODUCT_FARM_TRANSFER" + outputModeTable = "table" + outputModeJSON = "json" +) + +// commandOptions holds all parsed CLI flags. +type commandOptions struct { + Apply bool + RollbackRunID string + LocationID uint + LocationName string + TransferDate time.Time + TransferDateRaw string + AllLocations bool + Output string + ActorID uint + RunID string +} + +// farmWarehouseInfo holds resolved farm-level warehouse data for a location. +// If FarmCount > 1, the location is invalid (ambiguous target). +type farmWarehouseInfo struct { + LocationID uint + LocationName string + FarmCount int + WarehouseID uint // only reliable when FarmCount == 1 + WarehouseName string // only reliable when FarmCount == 1 +} + +// kandangStockRow is the raw row loaded from the DB for a single product in a kandang warehouse. +type kandangStockRow struct { + LocationID uint + LocationName string + SourceWarehouseID uint + SourceWarehouseName string + ProductWarehouseID uint + ProductID uint + ProductName string + OnHandQty float64 + AllocatedQty float64 // sum of ACTIVE CONSUME stock allocations + LeftoverQty float64 // OnHandQty - AllocatedQty +} + +// transferReportRow is one row in the plan/apply report. +type transferReportRow 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"` + OnHandQty float64 `json:"on_hand_qty"` + AllocatedQty float64 `json:"allocated_qty"` + Qty float64 `json:"qty"` // leftover qty to transfer + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + TransferID *uint64 `json:"transfer_id,omitempty"` + MovementNumber *string `json:"movement_number,omitempty"` +} + +// transferGroup is a single stock transfer: one kandang warehouse → one farm warehouse, with N products. +type transferGroup struct { + LocationID uint + LocationName string + SourceWarehouseID uint + SourceWarehouseName string + FarmWarehouseID uint + FarmWarehouseName string + Rows []*transferReportRow +} + +// applySummary is printed at the end of a plan or apply run. +type applySummary struct { + RowsPlanned int `json:"rows_planned"` + RowsApplied int `json:"rows_applied"` + RowsSkipped int `json:"rows_skipped"` + RowsError int `json:"rows_error"` + RowsFailed int `json:"rows_failed"` + GroupsPlanned int `json:"groups_planned"` + GroupsApplied int `json:"groups_applied"` +} + +// rollbackDetailRow is one product line that belongs to a transfer created by a previous run. +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"` +} + +// systemTransferExecutor abstracts the transfer service so it can be faked in tests. +type systemTransferExecutor interface { + CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) + DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error +} + +func main() { + opts, err := parseFlags() + if err != nil { + log.Fatalf("invalid flags: %v", err) + } + + db := database.Connect(config.DBHost, config.DBName) + ctx := context.Background() + + // ── Rollback path ──────────────────────────────────────────────────────── + 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 len(rows) == 0 { + fmt.Fprintf(os.Stderr, "no transfers found for run_id=%s\n", opts.RollbackRunID) + os.Exit(1) + } + 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 + } + + // ── Plan / Apply path ──────────────────────────────────────────────────── + farmMap, err := loadFarmWarehouseMap(ctx, db, opts) + if err != nil { + log.Fatalf("failed to load farm warehouse map: %v", err) + } + + // Abort early if any in-scope location has multiple farm warehouses and + // we are about to apply — the ambiguity is too risky to proceed. + if opts.Apply { + if msgs := validateFarmWarehouseMap(farmMap); len(msgs) > 0 { + for _, m := range msgs { + fmt.Fprintln(os.Stderr, "ERROR:", m) + } + log.Fatalf("aborting: resolve multiple-farm-warehouse conflicts before applying") + } + } + + stockRows, err := loadKandangLeftoverStocks(ctx, db, opts) + if err != nil { + log.Fatalf("failed to load kandang leftover stocks: %v", err) + } + + reportRows, groups := buildTransferPlan(opts, farmMap, stockRows) + + if !opts.Apply { + renderTransferReport(opts.Output, reportRows, summarizeReport(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 = summarizeReport(finalRows, groups, summary.GroupsApplied) + renderTransferReport(opts.Output, finalRows, summary) +} + +// ── Flag parsing ────────────────────────────────────────────────────────────── + +func parseFlags() (*commandOptions, error) { + var opts commandOptions + flag.BoolVar(&opts.Apply, "apply", false, "Apply transfers. If false, run as dry-run and print the plan") + 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.TransferDateRaw, "transfer-date", "", "Transfer date in YYYY-MM-DD format (default: today in Asia/Jakarta)") + flag.BoolVar(&opts.AllLocations, "all-locations", false, "Allow apply without a location filter (transfers all locations)") + 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; must be 'table' or 'json'", opts.Output) + } + + 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.TransferDateRaw != "" { + return nil, errors.New("--transfer-date is not used with --rollback-run-id") + } + } else if opts.Apply { + if !opts.AllLocations && opts.LocationID == 0 && opts.LocationName == "" { + return nil, errors.New( + "apply mode requires --location-id, --location-name, or --all-locations for safety; " + + "use --all-locations only when you have reviewed the dry-run output for all locations", + ) + } + } + + jakarta := time.FixedZone("Asia/Jakarta", 7*3600) + if strings.TrimSpace(opts.TransferDateRaw) == "" { + opts.TransferDate = normalizeDateOnly(time.Now().In(jakarta)) + } else { + t, err := time.Parse("2006-01-02", opts.TransferDateRaw) + if err != nil { + return nil, fmt.Errorf("invalid --transfer-date: %w", err) + } + opts.TransferDate = normalizeDateOnly(t) + } + + opts.RunID = buildRunID() + return &opts, nil +} + +// ── Service wiring ──────────────────────────────────────────────────────────── + +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, + ) +} + +// ── DB loading ──────────────────────────────────────────────────────────────── + +// loadFarmWarehouseMap returns a map keyed by location_id. +// Each entry tells how many LOKASI-type warehouses the location has and which one +// to use (only safe when FarmCount == 1). +func loadFarmWarehouseMap(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]farmWarehouseInfo, error) { + type row struct { + LocationID uint `gorm:"column:location_id"` + LocationName string `gorm:"column:location_name"` + FarmCount int `gorm:"column:farm_count"` + WarehouseID uint `gorm:"column:warehouse_id"` + WarehouseName string `gorm:"column:warehouse_name"` + } + + query := db.WithContext(ctx). + Table("warehouses kw"). + Select(` + kw.location_id AS location_id, + l.name AS location_name, + COUNT(fw.id) AS farm_count, + MIN(fw.id) AS warehouse_id, + MIN(fw.name) AS warehouse_name + `). + Joins("JOIN locations l ON l.id = kw.location_id"). + Joins(`LEFT JOIN warehouses fw + ON fw.location_id = kw.location_id + AND UPPER(fw.type) = 'LOKASI' + AND fw.deleted_at IS NULL`). + Where("UPPER(kw.type) = 'KANDANG'"). + Where("kw.deleted_at IS NULL"). + Group("kw.location_id, l.name") + + query = applyLocationFilter(query, opts, "kw") + + var rows []row + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]farmWarehouseInfo, len(rows)) + for _, r := range rows { + result[r.LocationID] = farmWarehouseInfo{ + LocationID: r.LocationID, + LocationName: r.LocationName, + FarmCount: r.FarmCount, + WarehouseID: r.WarehouseID, + WarehouseName: r.WarehouseName, + } + } + return result, nil +} + +// validateFarmWarehouseMap returns one error message per location that has +// more than one farm-level (LOKASI) warehouse. An empty slice means no issues. +func validateFarmWarehouseMap(m map[uint]farmWarehouseInfo) []string { + var msgs []string + for _, info := range m { + if info.FarmCount > 1 { + msgs = append(msgs, fmt.Sprintf( + "location %q (id=%d) has %d LOKASI warehouses; a unique farm warehouse is required — resolve the ambiguity before running", + info.LocationName, info.LocationID, info.FarmCount, + )) + } + } + sort.Strings(msgs) + return msgs +} + +// loadKandangLeftoverStocks loads every product_warehouse row for KANDANG-type +// warehouses where on_hand_qty > 0, together with the sum of ACTIVE CONSUME +// stock_allocations so callers can compute the leftover qty. +func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]kandangStockRow, 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"` + 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"` + AllocatedQty float64 `gorm:"column:allocated_qty"` + } + + 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, + 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, + COALESCE(( + SELECT SUM(sa.qty) + FROM stock_allocations sa + WHERE sa.product_warehouse_id = pw.id + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + AND sa.deleted_at IS NULL + ), 0) AS allocated_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 AND p.deleted_at IS NULL"). + Where("UPPER(kw.type) = 'KANDANG'"). + Where("COALESCE(pw.qty, 0) > 0"). + Order("l.name ASC, kw.name ASC, p.name ASC") + + query = applyLocationFilter(query, opts, "kw") + + var rows []row + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + result := make([]kandangStockRow, 0, len(rows)) + for _, r := range rows { + result = append(result, kandangStockRow{ + LocationID: r.LocationID, + LocationName: r.LocationName, + SourceWarehouseID: r.SourceWarehouseID, + SourceWarehouseName: r.SourceWarehouseName, + ProductWarehouseID: r.ProductWarehouseID, + ProductID: r.ProductID, + ProductName: r.ProductName, + OnHandQty: r.OnHandQty, + AllocatedQty: r.AllocatedQty, + LeftoverQty: r.OnHandQty - r.AllocatedQty, + }) + } + return result, nil +} + +// ── Plan building ───────────────────────────────────────────────────────────── + +func buildTransferPlan( + opts *commandOptions, + farmMap map[uint]farmWarehouseInfo, + stocks []kandangStockRow, +) ([]transferReportRow, []transferGroup) { + reportRows := make([]transferReportRow, 0, len(stocks)) + groupMap := make(map[string]*transferGroup) // key: "srcWarehouseID:farmWarehouseID" + + for _, s := range stocks { + farm := farmMap[s.LocationID] + + report := transferReportRow{ + RunID: opts.RunID, + LocationID: s.LocationID, + LocationName: s.LocationName, + SourceWarehouseID: s.SourceWarehouseID, + SourceWarehouseName: s.SourceWarehouseName, + ProductWarehouseID: s.ProductWarehouseID, + ProductID: s.ProductID, + ProductName: s.ProductName, + OnHandQty: s.OnHandQty, + AllocatedQty: s.AllocatedQty, + Qty: s.LeftoverQty, + Status: "eligible", + } + + switch { + case farm.FarmCount == 0: + report.Status = "skipped" + report.Reason = "missing_farm_warehouse" + case farm.FarmCount > 1: + // Treat as a hard error row so the operator knows to fix it. + report.Status = "error" + report.Reason = fmt.Sprintf("multiple_farm_warehouses (found %d)", farm.FarmCount) + case s.LeftoverQty <= 0: + report.Status = "skipped" + if s.AllocatedQty > 0 { + report.Reason = fmt.Sprintf("fully_allocated (on_hand=%.3f allocated=%.3f)", s.OnHandQty, s.AllocatedQty) + } else { + report.Reason = "zero_on_hand_qty" + } + } + + if farm.FarmCount == 1 { + fwID := farm.WarehouseID + fwName := farm.WarehouseName + report.FarmWarehouseID = &fwID + report.FarmWarehouseName = &fwName + } + + reportRows = append(reportRows, report) + if report.Status != "eligible" { + continue + } + + groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.WarehouseID) + grp := groupMap[groupKey] + if grp == nil { + grp = &transferGroup{ + LocationID: s.LocationID, + LocationName: s.LocationName, + SourceWarehouseID: s.SourceWarehouseID, + SourceWarehouseName: s.SourceWarehouseName, + FarmWarehouseID: farm.WarehouseID, + FarmWarehouseName: farm.WarehouseName, + } + groupMap[groupKey] = grp + } + grp.Rows = append(grp.Rows, &reportRows[len(reportRows)-1]) + } + + groups := make([]transferGroup, 0, len(groupMap)) + for _, grp := range groupMap { + sort.Slice(grp.Rows, func(i, j int) bool { + return grp.Rows[i].ProductName < grp.Rows[j].ProductName + }) + groups = append(groups, *grp) + } + 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 +} + +// ── Apply / Rollback execution ──────────────────────────────────────────────── + +func executeApply( + ctx context.Context, + svc systemTransferExecutor, + opts *commandOptions, + groups []transferGroup, +) (applySummary, error) { + summary := applySummary{GroupsPlanned: len(groups)} + + for i := range groups { + grp := &groups[i] + products := make([]transferSvc.SystemTransferProduct, 0, len(grp.Rows)) + for _, row := range grp.Rows { + products = append(products, transferSvc.SystemTransferProduct{ + ProductID: row.ProductID, + ProductQty: row.Qty, + }) + } + + reason := buildTransferReason(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate) + notes := buildStockLogNotes(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate) + + transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{ + TransferReason: reason, + TransferDate: opts.TransferDate, + SourceWarehouseID: grp.SourceWarehouseID, + DestinationWarehouseID: grp.FarmWarehouseID, + Products: products, + ActorID: opts.ActorID, + StockLogNotes: notes, + }) + if err != nil { + for _, row := range grp.Rows { + row.Status = "failed" + row.Reason = err.Error() + summary.RowsFailed++ + } + continue + } + + summary.GroupsApplied++ + for _, row := range grp.Rows { + row.Status = "applied" + row.TransferID = &transfer.Id + row.MovementNumber = &transfer.MovementNumber + summary.RowsApplied++ + } + } + + for _, grp := range groups { + summary.RowsPlanned += len(grp.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 id := range byTransfer { + transferIDs = append(transferIDs, id) + } + // Delete in descending order to minimize downstream conflicts. + sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] }) + + var firstErr error + for _, id := range transferIDs { + err := svc.DeleteSystemTransfer(ctx, uint(id), actorID) + for _, idx := range byTransfer[id] { + 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 +} + +// loadRollbackDetails finds all stock transfer rows that were created by the +// given run_id (matched via the transfer reason field). +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 _, r := range dbRows { + rows = append(rows, rollbackDetailRow{ + RunID: runID, + TransferID: r.TransferID, + MovementNumber: r.MovementNumber, + LocationName: r.LocationName, + SourceWarehouseName: r.SourceWarehouseName, + FarmWarehouseName: r.FarmWarehouseName, + ProductName: r.ProductName, + Qty: r.Qty, + }) + } + return rows, nil +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func applyLocationFilter(q *gorm.DB, opts *commandOptions, tableAlias string) *gorm.DB { + if opts == nil { + return q + } + switch { + case opts.LocationID > 0: + return q.Where(tableAlias+".location_id = ?", opts.LocationID) + case opts.LocationName != "": + return q.Where("LOWER(l.name) = LOWER(?)", opts.LocationName) + default: + return q + } +} + +// buildTransferReason produces a structured string stored in stock_transfers.reason. +// It is used as the rollback lookup key, so must remain stable and parseable. +func buildTransferReason(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time) string { + return fmt.Sprintf( + "%s|run_id=%s|location=%s|src_warehouse=%s|farm_warehouse=%s|transfer_date=%s", + transferReasonPrefix, + runID, + sanitizePipeField(locationName), + sanitizePipeField(srcWarehouse), + sanitizePipeField(farmWarehouse), + date.Format("2006-01-02"), + ) +} + +// buildStockLogNotes produces a human-readable note attached to each stock log +// entry so operators can trace the origin of stock movements in the logs. +func buildStockLogNotes(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time) string { + return fmt.Sprintf( + "[auto] leftover stock transfer from kandang to farm | run_id=%s | location=%s | from=%s | to=%s | date=%s", + runID, + locationName, + srcWarehouse, + farmWarehouse, + date.Format("2006-01-02"), + ) +} + +// buildRunReasonMatcher returns a LIKE pattern that matches all transfers from +// a specific run_id regardless of the other fields in the reason string. +func buildRunReasonMatcher(runID string) string { + return fmt.Sprintf("%s|run_id=%s|%%", transferReasonPrefix, strings.TrimSpace(runID)) +} + +func buildRunID() string { + return fmt.Sprintf("product-farm-transfer-%s", time.Now().UTC().Format("20060102T150405.000000000Z")) +} + +func sanitizePipeField(s string) string { + return strings.ReplaceAll(strings.TrimSpace(s), "|", "/") +} + +func normalizeDateOnly(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) +} + +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + +// flattenGroups rebuilds reportRows from groups (which carry applied/failed +// status) then appends skipped/error rows from the original slice. +func flattenGroups(groups []transferGroup, fallback []transferReportRow) []transferReportRow { + if len(groups) == 0 { + return fallback + } + rows := make([]transferReportRow, 0, len(fallback)) + for _, grp := range groups { + for _, row := range grp.Rows { + rows = append(rows, *row) + } + } + for _, row := range fallback { + if row.Status == "skipped" || row.Status == "error" { + 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 summarizeReport(rows []transferReportRow, 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++ + case "error": + summary.RowsError++ + } + } + return summary +} + +// ── Rendering ───────────────────────────────────────────────────────────────── + +func renderTransferReport(mode string, rows []transferReportRow, summary applySummary) { + if mode == outputModeJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(map[string]any{"rows": rows, "summary": summary}) + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tON_HAND\tALLOCATED\tLEFTOVER\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER") + for _, row := range rows { + transferID := "-" + if row.TransferID != nil { + transferID = fmt.Sprintf("%d", *row.TransferID) + } + movementNumber := "-" + if row.MovementNumber != nil { + movementNumber = *row.MovementNumber + } + fmt.Fprintf(w, + "%s\t%s\t%s\t%s\t%s\t%.3f\t%.3f\t%.3f\t%s\t%s\t%s\t%s\n", + row.RunID, + row.LocationName, + row.SourceWarehouseName, + derefString(row.FarmWarehouseName), + row.ProductName, + row.OnHandQty, + row.AllocatedQty, + row.Qty, + row.Status, + row.Reason, + transferID, + movementNumber, + ) + } + _ = w.Flush() + fmt.Printf( + "\nSummary: rows_planned=%d rows_applied=%d rows_skipped=%d rows_error=%d rows_failed=%d groups_planned=%d groups_applied=%d\n", + summary.RowsPlanned, summary.RowsApplied, summary.RowsSkipped, summary.RowsError, 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/auto-transfer-products-to-farm/main_test.go b/cmd/auto-transfer-products-to-farm/main_test.go new file mode 100644 index 00000000..4b42efbb --- /dev/null +++ b/cmd/auto-transfer-products-to-farm/main_test.go @@ -0,0 +1,373 @@ +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" +) + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func ptrUint(v uint) *uint { return &v } +func ptrStr(s string) *string { return &s } +func ptrUint64(v uint64) *uint64 { return &v } + +// fakeSystemTransferExecutor records calls and returns pre-configured responses. +type fakeSystemTransferExecutor struct { + createRequests []*transferSvc.SystemTransferRequest + createResponses []*entity.StockTransfer + createErrors []error + deletedTransferIDs []uint + deleteErrors map[uint]error +} + +func (f *fakeSystemTransferExecutor) CreateSystemTransfer(_ 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(_ context.Context, id uint, _ uint) error { + f.deletedTransferIDs = append(f.deletedTransferIDs, id) + if f.deleteErrors != nil { + return f.deleteErrors[id] + } + return nil +} + +// ── validateFarmWarehouseMap ────────────────────────────────────────────────── + +func TestValidateFarmWarehouseMapReturnsMsgsForMultipleFarmWarehouses(t *testing.T) { + m := map[uint]farmWarehouseInfo{ + 1: {LocationID: 1, LocationName: "Jamali", FarmCount: 1}, + 2: {LocationID: 2, LocationName: "Cijangkar", FarmCount: 3}, + 3: {LocationID: 3, LocationName: "Tamansari", FarmCount: 2}, + } + + msgs := validateFarmWarehouseMap(m) + if len(msgs) != 2 { + t.Fatalf("expected 2 error messages, got %d: %v", len(msgs), msgs) + } + for _, msg := range msgs { + if !strings.Contains(msg, "LOKASI warehouses") { + t.Errorf("expected message to mention LOKASI warehouses, got: %s", msg) + } + } +} + +func TestValidateFarmWarehouseMapNoErrorsWhenAllUnique(t *testing.T) { + m := map[uint]farmWarehouseInfo{ + 1: {LocationID: 1, LocationName: "Jamali", FarmCount: 1}, + 2: {LocationID: 2, LocationName: "Cijangkar", FarmCount: 0}, + } + if msgs := validateFarmWarehouseMap(m); len(msgs) != 0 { + t.Fatalf("expected no messages, got: %v", msgs) + } +} + +// ── buildTransferPlan ───────────────────────────────────────────────────────── + +func TestBuildTransferPlanEligibleRowsGroupedByWarehousePair(t *testing.T) { + opts := &commandOptions{RunID: "product-farm-transfer-test"} + farmMap := map[uint]farmWarehouseInfo{ + 10: {LocationID: 10, LocationName: "Jamali", FarmCount: 1, WarehouseID: 50, WarehouseName: "Gudang Farm Jamali"}, + } + stocks := []kandangStockRow{ + {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "Gudang K1", ProductWarehouseID: 101, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 0, LeftoverQty: 100}, + {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "Gudang K1", ProductWarehouseID: 102, ProductID: 2, ProductName: "OVK B", OnHandQty: 50, AllocatedQty: 10, LeftoverQty: 40}, + } + + reportRows, groups := buildTransferPlan(opts, farmMap, stocks) + if len(reportRows) != 2 { + t.Fatalf("expected 2 report rows, got %d", len(reportRows)) + } + if len(groups) != 1 { + t.Fatalf("expected 1 transfer group, got %d", len(groups)) + } + if len(groups[0].Rows) != 2 { + t.Fatalf("expected 2 products in group, got %d", len(groups[0].Rows)) + } + if reportRows[1].AllocatedQty != 10 || reportRows[1].Qty != 40 { + t.Errorf("unexpected allocated/leftover qty for OVK B: %+v", reportRows[1]) + } + for _, row := range reportRows { + if row.Status != "eligible" { + t.Errorf("expected eligible, got %s for %s", row.Status, row.ProductName) + } + } +} + +func TestBuildTransferPlanSkipsMissingFarmWarehouse(t *testing.T) { + opts := &commandOptions{RunID: "product-farm-transfer-test"} + farmMap := map[uint]farmWarehouseInfo{ + 10: {LocationID: 10, LocationName: "Jamali", FarmCount: 0}, + } + stocks := []kandangStockRow{ + {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "Gudang K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100}, + } + + reportRows, groups := buildTransferPlan(opts, farmMap, stocks) + if len(groups) != 0 { + t.Fatalf("expected no transfer groups, got %d", len(groups)) + } + if reportRows[0].Status != "skipped" || reportRows[0].Reason != "missing_farm_warehouse" { + t.Errorf("unexpected status/reason: %s / %s", reportRows[0].Status, reportRows[0].Reason) + } +} + +func TestBuildTransferPlanMarksErrorForMultipleFarmWarehouses(t *testing.T) { + opts := &commandOptions{RunID: "product-farm-transfer-test"} + farmMap := map[uint]farmWarehouseInfo{ + 10: {LocationID: 10, LocationName: "Cijangkar", FarmCount: 2}, + } + stocks := []kandangStockRow{ + {LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, SourceWarehouseName: "Gudang K2", ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200}, + } + + reportRows, groups := buildTransferPlan(opts, farmMap, stocks) + if len(groups) != 0 { + t.Fatalf("expected no transfer groups, got %d", len(groups)) + } + if reportRows[0].Status != "error" { + t.Errorf("expected error status for multiple farm warehouses, got %s", reportRows[0].Status) + } + if !strings.Contains(reportRows[0].Reason, "multiple_farm_warehouses") { + t.Errorf("unexpected reason: %s", reportRows[0].Reason) + } +} + +func TestBuildTransferPlanSkipsFullyAllocatedStock(t *testing.T) { + opts := &commandOptions{RunID: "product-farm-transfer-test"} + farmMap := map[uint]farmWarehouseInfo{ + 10: {LocationID: 10, LocationName: "Jamali", FarmCount: 1, WarehouseID: 50, WarehouseName: "Gudang Farm Jamali"}, + } + stocks := []kandangStockRow{ + // fully allocated + {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0}, + // partially allocated, should be eligible with leftover qty + {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 2, ProductName: "OVK B", OnHandQty: 80, AllocatedQty: 30, LeftoverQty: 50}, + } + + reportRows, groups := buildTransferPlan(opts, farmMap, stocks) + if len(groups) != 1 || len(groups[0].Rows) != 1 { + t.Fatalf("expected 1 group with 1 eligible row, got groups=%d", len(groups)) + } + if reportRows[0].Status != "skipped" { + t.Errorf("expected fully-allocated row to be skipped, got %s", reportRows[0].Status) + } + if !strings.Contains(reportRows[0].Reason, "fully_allocated") { + t.Errorf("unexpected reason: %s", reportRows[0].Reason) + } + if groups[0].Rows[0].Qty != 50 { + t.Errorf("expected leftover qty 50, got %.3f", groups[0].Rows[0].Qty) + } +} + +// ── executeApply ────────────────────────────────────────────────────────────── + +func TestExecuteApplyCreatesTransfersWithTaggedReasonAndNotes(t *testing.T) { + date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC) + opts := &commandOptions{ + RunID: "product-farm-transfer-apply", + TransferDate: date, + ActorID: 99, + } + + groups := []transferGroup{ + { + LocationID: 10, + LocationName: "Jamali", + SourceWarehouseID: 20, + SourceWarehouseName: "Gudang K1", + FarmWarehouseID: 50, + FarmWarehouseName: "Gudang Farm Jamali", + Rows: []*transferReportRow{ + {ProductID: 1, ProductName: "Pakan A", Qty: 100}, + {ProductID: 2, ProductName: "OVK B", Qty: 40}, + }, + }, + { + LocationID: 11, + LocationName: "Tamansari", + SourceWarehouseID: 30, + SourceWarehouseName: "Gudang K3", + FarmWarehouseID: 60, + FarmWarehouseName: "Gudang Farm Tamansari", + Rows: []*transferReportRow{ + {ProductID: 3, ProductName: "Pakan C", Qty: 200}, + }, + }, + } + + 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("unexpected error: %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)) + } + + reason := executor.createRequests[0].TransferReason + if !strings.HasPrefix(reason, transferReasonPrefix) { + t.Errorf("reason must start with prefix %q, got: %s", transferReasonPrefix, reason) + } + if !strings.Contains(reason, "run_id=product-farm-transfer-apply") { + t.Errorf("reason must contain run_id, got: %s", reason) + } + if !strings.Contains(reason, "location=Jamali") { + t.Errorf("reason must contain location, got: %s", reason) + } + if !strings.Contains(reason, "transfer_date=2026-04-24") { + t.Errorf("reason must contain transfer_date, got: %s", reason) + } + + notes := executor.createRequests[0].StockLogNotes + if !strings.Contains(notes, "[auto] leftover stock transfer from kandang to farm") { + t.Errorf("stock log notes must be human-readable, got: %s", notes) + } + if !strings.Contains(notes, "Jamali") { + t.Errorf("stock log notes must contain location name, got: %s", notes) + } + + if executor.createRequests[0].MovementNumber != "" { + t.Errorf("movement number should be empty so the service generates one, got: %q", executor.createRequests[0].MovementNumber) + } + if groups[0].Rows[0].Status != "applied" || groups[0].Rows[1].Status != "applied" { + t.Errorf("first group rows must be applied: %+v", groups[0].Rows) + } + if groups[1].Rows[0].Status != "failed" { + t.Errorf("second group row must be failed: %+v", groups[1].Rows[0]) + } + if groups[0].Rows[0].TransferID == nil || *groups[0].Rows[0].TransferID != 1001 { + t.Errorf("first group must carry transfer id 1001") + } +} + +// ── executeRollback ─────────────────────────────────────────────────────────── + +func TestExecuteRollbackDeletesInDescendingOrderAndMarksStatuses(t *testing.T) { + executor := &fakeSystemTransferExecutor{ + deleteErrors: map[uint]error{ + 200: errors.New("stock already consumed downstream"), + }, + } + rows := []rollbackDetailRow{ + {TransferID: 100, ProductName: "Pakan A"}, + {TransferID: 200, ProductName: "OVK B"}, + {TransferID: 100, ProductName: "Pakan C"}, + } + + err := executeRollback(context.Background(), executor, rows, 99) + if err == nil { + t.Fatal("expected rollback error for transfer 200") + } + if !strings.Contains(err.Error(), "stock already consumed downstream") { + t.Fatalf("unexpected error: %v", err) + } + if len(executor.deletedTransferIDs) != 2 { + t.Fatalf("expected 2 delete calls, got %d", len(executor.deletedTransferIDs)) + } + // descending: 200 before 100 + if executor.deletedTransferIDs[0] != 200 || executor.deletedTransferIDs[1] != 100 { + t.Fatalf("expected delete order [200 100], got %v", executor.deletedTransferIDs) + } + if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" { + t.Fatalf("transfer 100 rows must be rolled_back: %+v", rows) + } + if rows[1].Status != "failed" { + t.Fatalf("transfer 200 row must be failed: %+v", rows[1]) + } +} + +func TestExecuteRollbackRequiresActorID(t *testing.T) { + err := executeRollback(context.Background(), &fakeSystemTransferExecutor{}, []rollbackDetailRow{{TransferID: 1}}, 0) + if err == nil || !strings.Contains(err.Error(), "actor-id") { + t.Fatalf("expected actor-id error, got: %v", err) + } +} + +// ── buildTransferReason / buildRunReasonMatcher ─────────────────────────────── + +func TestBuildTransferReasonIsMatchedByRunReasonMatcher(t *testing.T) { + runID := "product-farm-transfer-20260424T120000.000000000Z" + date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC) + reason := buildTransferReason(runID, "Jamali", "Gudang K1", "Gudang Farm Jamali", date) + + matcher := buildRunReasonMatcher(runID) + // Simulate a LIKE match: matcher ends with % so check prefix. + needle := strings.TrimSuffix(matcher, "%") + if !strings.HasPrefix(reason, needle) { + t.Errorf("reason %q does not match matcher prefix %q", reason, needle) + } +} + +func TestBuildTransferReasonSanitizesPipes(t *testing.T) { + reason := buildTransferReason("run-1", "Lok|asi", "Gudang|K1", "Farm|WH", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + // Pipes inside field values must be replaced so the structured format stays parseable. + parts := strings.Split(reason, "|") + // Expect exactly 6 pipe-separated segments (prefix + 5 key=value pairs). + if len(parts) != 6 { + t.Errorf("expected 6 pipe segments, got %d: %v", len(parts), parts) + } +} + +// ── summarizeReport ─────────────────────────────────────────────────────────── + +func TestSummarizeReportCountsCorrectly(t *testing.T) { + rows := []transferReportRow{ + {Status: "eligible"}, + {Status: "applied"}, + {Status: "applied"}, + {Status: "skipped"}, + {Status: "error"}, + {Status: "failed"}, + } + groups := []transferGroup{{}, {}} + s := summarizeReport(rows, groups, 1) + + if s.RowsPlanned != 4 { // eligible + 2 applied + 1 failed + t.Errorf("expected RowsPlanned=4, got %d", s.RowsPlanned) + } + if s.RowsApplied != 2 { + t.Errorf("expected RowsApplied=2, got %d", s.RowsApplied) + } + if s.RowsSkipped != 1 { + t.Errorf("expected RowsSkipped=1, got %d", s.RowsSkipped) + } + if s.RowsError != 1 { + t.Errorf("expected RowsError=1, got %d", s.RowsError) + } + if s.RowsFailed != 1 { + t.Errorf("expected RowsFailed=1, got %d", s.RowsFailed) + } + if s.GroupsPlanned != 2 || s.GroupsApplied != 1 { + t.Errorf("unexpected group counts: planned=%d applied=%d", s.GroupsPlanned, s.GroupsApplied) + } +}