mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 05:21:57 +00:00
Merge branch 'cmd/auto-transfer-products-to-farm' into 'development'
cmd: new command to auto transfer leftover stocks from kandang to farm See merge request mbugroup/lti-api!468
This commit is contained in:
Executable
BIN
Binary file not shown.
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user