Merge branch 'development' into 'production'

Development

See merge request mbugroup/lti-api!473
This commit is contained in:
Adnan Zahir
2026-04-24 21:24:56 +07:00
3 changed files with 807 additions and 277 deletions
Binary file not shown.
+378 -76
View File
@@ -32,6 +32,9 @@ const (
transferReasonPrefix = "PRODUCT_FARM_TRANSFER"
outputModeTable = "table"
outputModeJSON = "json"
sourceTypeKandang = "kandang_to_farm"
sourceTypeFarmConsol = "farm_consolidation"
)
// commandOptions holds all parsed CLI flags.
@@ -43,22 +46,49 @@ type commandOptions struct {
TransferDate time.Time
TransferDateRaw string
AllLocations bool
FarmWarehouseOverrideID uint
SkipAmbiguous bool
// FlagFilter is an optional set of product flag names (upper-cased).
// When non-empty only products that carry at least one of these flags are
// included. Populated from --flags="PAKAN,OVK".
FlagFilter []string
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).
// farmWarehouseEntry is a single LOKASI-type warehouse belonging to a location.
type farmWarehouseEntry struct {
ID uint
Name string
}
// farmWarehouseInfo holds all LOKASI warehouses for a location plus the
// resolved target warehouse (ChosenID). When a location has exactly one
// LOKASI warehouse, ChosenID is set automatically. When multiple exist,
// ChosenID is only set after applying --farm-warehouse-id; until then it
// stays 0 and the location is flagged as an error.
type farmWarehouseInfo struct {
LocationID uint
LocationName string
FarmCount int
WarehouseID uint // only reliable when FarmCount == 1
WarehouseName string // only reliable when FarmCount == 1
// AllFarm holds every LOKASI warehouse found for this location, sorted by id.
AllFarm []farmWarehouseEntry
// ChosenID is the resolved transfer destination (0 = unresolved ambiguity).
ChosenID uint
ChosenName string
// OtherFarm holds non-chosen LOKASI warehouses that must be consolidated
// into ChosenID. Populated only when --farm-warehouse-id resolves a
// multi-warehouse location.
OtherFarm []farmWarehouseEntry
}
// kandangStockRow is the raw row loaded from the DB for a single product in a kandang warehouse.
func (f farmWarehouseInfo) farmCount() int { return len(f.AllFarm) }
func (f farmWarehouseInfo) isResolved() bool { return f.ChosenID > 0 }
func (f farmWarehouseInfo) hasFarm() bool { return len(f.AllFarm) > 0 }
// kandangStockRow is a single product-warehouse row loaded from the DB.
// SourceType distinguishes ordinary kandang stocks from extra farm-warehouse
// stocks that need inter-farm consolidation.
type kandangStockRow struct {
LocationID uint
LocationName string
@@ -68,13 +98,15 @@ type kandangStockRow struct {
ProductID uint
ProductName string
OnHandQty float64
AllocatedQty float64 // sum of ACTIVE CONSUME stock allocations
AllocatedQty float64 // sum of ACTIVE CONSUME stock_allocations
LeftoverQty float64 // OnHandQty - AllocatedQty
SourceType string // sourceTypeKandang or sourceTypeFarmConsol
}
// transferReportRow is one row in the plan/apply report.
type transferReportRow struct {
RunID string `json:"run_id"`
SourceType string `json:"source_type"`
LocationID uint `json:"location_id"`
LocationName string `json:"location_name"`
SourceWarehouseID uint `json:"source_warehouse_id"`
@@ -93,8 +125,9 @@ type transferReportRow struct {
MovementNumber *string `json:"movement_number,omitempty"`
}
// transferGroup is a single stock transfer: one kandang warehouse → one farm warehouse, with N products.
// transferGroup is one stock transfer document: source → farm, N products.
type transferGroup struct {
SourceType string
LocationID uint
LocationName string
SourceWarehouseID uint
@@ -115,7 +148,7 @@ type applySummary struct {
GroupsApplied int `json:"groups_applied"`
}
// rollbackDetailRow is one product line that belongs to a transfer created by a previous run.
// rollbackDetailRow is one product line created by a previous run.
type rollbackDetailRow struct {
RunID string `json:"run_id"`
TransferID uint64 `json:"transfer_id"`
@@ -129,12 +162,14 @@ type rollbackDetailRow struct {
Reason string `json:"reason,omitempty"`
}
// systemTransferExecutor abstracts the transfer service so it can be faked in tests.
// systemTransferExecutor abstracts the transfer service for testability.
type systemTransferExecutor interface {
CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
}
// ── Entry point ───────────────────────────────────────────────────────────────
func main() {
opts, err := parseFlags()
if err != nil {
@@ -169,28 +204,48 @@ func main() {
}
// ── Plan / Apply path ────────────────────────────────────────────────────
// Step 1: resolve which farm warehouse each location should use.
farmMap, err := loadFarmWarehouseMap(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load farm warehouse map: %v", err)
}
if err := applyFarmWarehouseOverride(farmMap, opts.FarmWarehouseOverrideID); err != nil {
log.Fatalf("invalid --farm-warehouse-id: %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.
// In apply mode, warn about or hard-stop on unresolved locations.
if opts.Apply {
if msgs := validateFarmWarehouseMap(farmMap); len(msgs) > 0 {
if msgs := listUnresolvedLocations(farmMap); len(msgs) > 0 {
for _, m := range msgs {
if opts.SkipAmbiguous {
fmt.Fprintln(os.Stderr, "WARN (skipping):", m)
} else {
fmt.Fprintln(os.Stderr, "ERROR:", m)
}
log.Fatalf("aborting: resolve multiple-farm-warehouse conflicts before applying")
}
if !opts.SkipAmbiguous {
log.Fatalf("aborting: use --farm-warehouse-id to choose the target warehouse for each location listed above, or pass --skip-ambiguous to skip them and process the rest")
}
}
}
stockRows, err := loadKandangLeftoverStocks(ctx, db, opts)
// Step 2: load leftover stocks from kandang warehouses.
kandangStocks, err := loadKandangLeftoverStocks(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load kandang leftover stocks: %v", err)
}
reportRows, groups := buildTransferPlan(opts, farmMap, stockRows)
// Step 3: load leftover stocks from extra farm warehouses that need
// consolidation into the chosen farm warehouse.
extraFarmStocks, err := loadExtraFarmLeftoverStocks(ctx, db, farmMap, opts)
if err != nil {
log.Fatalf("failed to load extra farm leftover stocks: %v", err)
}
// Step 4: merge and plan.
allStocks := append(kandangStocks, extraFarmStocks...)
reportRows, groups := buildTransferPlan(opts, farmMap, allStocks)
if !opts.Apply {
renderTransferReport(opts.Output, reportRows, summarizeReport(reportRows, groups, 0))
@@ -215,11 +270,31 @@ func parseFlags() (*commandOptions, error) {
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.BoolVar(&opts.AllLocations, "all-locations", false, "Allow apply without a location filter (transfers all locations at once)")
flag.BoolVar(&opts.SkipAmbiguous, "skip-ambiguous", false,
"When a location has multiple LOKASI warehouses and no --farm-warehouse-id is set, "+
"skip that location (status=skipped) instead of treating it as an error. "+
"Useful for an initial global run: unambiguous locations transfer immediately while "+
"ambiguous ones are left for a follow-up run with --farm-warehouse-id.")
flag.UintVar(&opts.FarmWarehouseOverrideID, "farm-warehouse-id", 0,
"When a location has multiple LOKASI warehouses, use this warehouse id as the chosen target. "+
"Stocks from the other LOKASI warehouses are also transferred to the chosen one. "+
"Requires --location-id or --location-name.")
var flagsRaw string
flag.StringVar(&flagsRaw, "flags", "",
"Comma-separated list of product flag names to include (e.g. PAKAN,OVK). "+
"Only products that carry at least one of these flags are transferred. "+
"Leave empty to transfer all products regardless of flags.")
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()
for _, f := range strings.Split(flagsRaw, ",") {
if name := strings.ToUpper(strings.TrimSpace(f)); name != "" {
opts.FlagFilter = append(opts.FlagFilter, name)
}
}
opts.LocationName = strings.TrimSpace(opts.LocationName)
opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID)
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
@@ -233,6 +308,16 @@ func parseFlags() (*commandOptions, error) {
if opts.LocationID > 0 && opts.LocationName != "" {
return nil, errors.New("use either --location-id or --location-name, not both")
}
if opts.FarmWarehouseOverrideID > 0 {
if opts.AllLocations {
return nil, errors.New("--farm-warehouse-id cannot be combined with --all-locations; specify --location-id or --location-name so the override targets the right location")
}
if opts.LocationID == 0 && opts.LocationName == "" {
return nil, errors.New("--farm-warehouse-id requires --location-id or --location-name")
}
}
if opts.RollbackRunID != "" {
if opts.LocationID > 0 || opts.LocationName != "" {
return nil, errors.New("location filters are not supported with --rollback-run-id")
@@ -240,11 +325,14 @@ func parseFlags() (*commandOptions, error) {
if opts.TransferDateRaw != "" {
return nil, errors.New("--transfer-date is not used with --rollback-run-id")
}
if opts.FarmWarehouseOverrideID > 0 {
return nil, errors.New("--farm-warehouse-id 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",
"use --all-locations only after reviewing the dry-run output for all locations",
)
}
}
@@ -299,26 +387,28 @@ func newSystemTransferService(db *gorm.DB) systemTransferExecutor {
// ── 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).
// loadFarmWarehouseMap returns one farmWarehouseInfo per location_id that has
// at least one KANDANG warehouse. It fetches every LOKASI warehouse for each
// location as distinct rows and builds the list in Go, so there is no
// aggregation that could miscount farm warehouses.
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"`
FarmWHID *uint `gorm:"column:farm_wh_id"`
FarmWHName *string `gorm:"column:farm_wh_name"`
}
// DISTINCT on (location_id, farm_wh_id) so multiple KANDANG warehouses in
// the same location don't produce duplicate farm-warehouse rows.
query := db.WithContext(ctx).
Table("warehouses kw").
Select(`
DISTINCT
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
fw.id AS farm_wh_id,
fw.name AS farm_wh_name
`).
Joins("JOIN locations l ON l.id = kw.location_id").
Joins(`LEFT JOIN warehouses fw
@@ -327,7 +417,7 @@ func loadFarmWarehouseMap(ctx context.Context, db *gorm.DB, opts *commandOptions
AND fw.deleted_at IS NULL`).
Where("UPPER(kw.type) = 'KANDANG'").
Where("kw.deleted_at IS NULL").
Group("kw.location_id, l.name")
Order("kw.location_id ASC, fw.id ASC")
query = applyLocationFilter(query, opts, "kw")
@@ -336,28 +426,97 @@ func loadFarmWarehouseMap(ctx context.Context, db *gorm.DB, opts *commandOptions
return nil, err
}
result := make(map[uint]farmWarehouseInfo, len(rows))
result := make(map[uint]farmWarehouseInfo)
for _, r := range rows {
result[r.LocationID] = farmWarehouseInfo{
LocationID: r.LocationID,
LocationName: r.LocationName,
FarmCount: r.FarmCount,
WarehouseID: r.WarehouseID,
WarehouseName: r.WarehouseName,
info := result[r.LocationID]
info.LocationID = r.LocationID
info.LocationName = r.LocationName
if r.FarmWHID != nil && *r.FarmWHID > 0 {
// Guard against duplicates that DISTINCT might not eliminate across
// different location_id groupings due to Go map updates.
alreadySeen := false
for _, e := range info.AllFarm {
if e.ID == *r.FarmWHID {
alreadySeen = true
break
}
}
if !alreadySeen {
info.AllFarm = append(info.AllFarm, farmWarehouseEntry{
ID: *r.FarmWHID,
Name: derefString(r.FarmWHName),
})
}
}
result[r.LocationID] = info
}
// Automatically resolve locations that have exactly one farm warehouse.
for locID, info := range result {
if len(info.AllFarm) == 1 {
info.ChosenID = info.AllFarm[0].ID
info.ChosenName = info.AllFarm[0].Name
result[locID] = info
}
}
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 {
// applyFarmWarehouseOverride sets ChosenID/OtherFarm on every location in the
// map that still has multiple unresolved farm warehouses. overrideID must
// appear in the location's AllFarm list; if it does not, an error is returned
// so the operator knows the ID is wrong before any transfer is attempted.
// Locations with 0 or 1 farm warehouses are left untouched.
func applyFarmWarehouseOverride(farmMap map[uint]farmWarehouseInfo, overrideID uint) error {
if overrideID == 0 {
return nil
}
for locID, info := range farmMap {
if len(info.AllFarm) <= 1 {
continue // no ambiguity; override is irrelevant for this location
}
found := false
others := make([]farmWarehouseEntry, 0, len(info.AllFarm)-1)
for _, fw := range info.AllFarm {
if fw.ID == overrideID {
info.ChosenID = fw.ID
info.ChosenName = fw.Name
found = true
} else {
others = append(others, fw)
}
}
if !found {
available := make([]string, 0, len(info.AllFarm))
for _, fw := range info.AllFarm {
available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID))
}
return fmt.Errorf(
"warehouse id %d is not a LOKASI warehouse for location %q (id=%d)\n available farm warehouses: %s",
overrideID, info.LocationName, info.LocationID, strings.Join(available, ", "),
)
}
info.OtherFarm = others
farmMap[locID] = info
}
return nil
}
// listUnresolvedLocations returns one human-readable error message per location
// that still has multiple farm warehouses with no override chosen.
func listUnresolvedLocations(farmMap map[uint]farmWarehouseInfo) []string {
var msgs []string
for _, info := range m {
if info.FarmCount > 1 {
for _, info := range farmMap {
if len(info.AllFarm) > 1 && !info.isResolved() {
available := make([]string, 0, len(info.AllFarm))
for _, fw := range info.AllFarm {
available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID))
}
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,
"location %q (id=%d) has %d LOKASI warehouses — rerun with --farm-warehouse-id=<id> to choose one: %s",
info.LocationName, info.LocationID, len(info.AllFarm), strings.Join(available, ", "),
))
}
}
@@ -365,9 +524,9 @@ func validateFarmWarehouseMap(m map[uint]farmWarehouseInfo) []string {
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.
// loadKandangLeftoverStocks returns all product_warehouse rows for KANDANG-type
// warehouses where on_hand_qty > 0, together with their active CONSUME
// allocations so the caller can derive leftover qty.
func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]kandangStockRow, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
@@ -409,6 +568,7 @@ func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOp
Order("l.name ASC, kw.name ASC, p.name ASC")
query = applyLocationFilter(query, opts, "kw")
query = applyFlagFilter(query, opts)
var rows []row
if err := query.Scan(&rows).Error; err != nil {
@@ -428,6 +588,102 @@ func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOp
OnHandQty: r.OnHandQty,
AllocatedQty: r.AllocatedQty,
LeftoverQty: r.OnHandQty - r.AllocatedQty,
SourceType: sourceTypeKandang,
})
}
return result, nil
}
// loadExtraFarmLeftoverStocks loads leftover stocks from every OtherFarm
// warehouse in the map. These are LOKASI-type warehouses that will be
// consolidated into the chosen farm warehouse when --farm-warehouse-id is used.
func loadExtraFarmLeftoverStocks(ctx context.Context, db *gorm.DB, farmMap map[uint]farmWarehouseInfo, opts *commandOptions) ([]kandangStockRow, error) {
// Collect extra farm warehouse IDs together with their location context.
type extraSource struct {
LocationID uint
LocationName string
WHID uint
WHName string
}
sources := make([]extraSource, 0)
for _, info := range farmMap {
for _, fw := range info.OtherFarm {
sources = append(sources, extraSource{
LocationID: info.LocationID,
LocationName: info.LocationName,
WHID: fw.ID,
WHName: fw.Name,
})
}
}
if len(sources) == 0 {
return nil, nil
}
warehouseIDs := make([]uint, 0, len(sources))
for _, s := range sources {
warehouseIDs = append(warehouseIDs, s.WHID)
}
type row struct {
WarehouseID uint `gorm:"column:source_warehouse_id"`
WarehouseName 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"`
}
var rows []row
q := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
fw.id AS source_warehouse_id,
fw.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 fw ON fw.id = pw.warehouse_id AND fw.deleted_at IS NULL").
Joins("JOIN products p ON p.id = pw.product_id AND p.deleted_at IS NULL").
Where("fw.id IN ?", warehouseIDs).
Where("COALESCE(pw.qty, 0) > 0")
q = applyFlagFilter(q, opts)
if err := q.Order("fw.name ASC, p.name ASC").Scan(&rows).Error; err != nil {
return nil, err
}
// Build a lookup: warehouseID → extraSource for location context.
srcByWH := make(map[uint]extraSource, len(sources))
for _, s := range sources {
srcByWH[s.WHID] = s
}
result := make([]kandangStockRow, 0, len(rows))
for _, r := range rows {
src := srcByWH[r.WarehouseID]
result = append(result, kandangStockRow{
LocationID: src.LocationID,
LocationName: src.LocationName,
SourceWarehouseID: r.WarehouseID,
SourceWarehouseName: r.WarehouseName,
ProductWarehouseID: r.ProductWarehouseID,
ProductID: r.ProductID,
ProductName: r.ProductName,
OnHandQty: r.OnHandQty,
AllocatedQty: r.AllocatedQty,
LeftoverQty: r.OnHandQty - r.AllocatedQty,
SourceType: sourceTypeFarmConsol,
})
}
return result, nil
@@ -448,6 +704,7 @@ func buildTransferPlan(
report := transferReportRow{
RunID: opts.RunID,
SourceType: s.SourceType,
LocationID: s.LocationID,
LocationName: s.LocationName,
SourceWarehouseID: s.SourceWarehouseID,
@@ -461,14 +718,42 @@ func buildTransferPlan(
Status: "eligible",
}
switch s.SourceType {
case sourceTypeFarmConsol:
// The destination is already resolved (OtherFarm is only populated
// when ChosenID is set). The only reason to skip is zero leftover.
if 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"
}
}
default: // sourceTypeKandang
switch {
case farm.FarmCount == 0:
case !farm.hasFarm():
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.
case farm.farmCount() > 1 && !farm.isResolved():
// Multiple LOKASI warehouses and no override was given. List the
// available warehouse IDs so the operator knows what to pass to
// --farm-warehouse-id.
available := make([]string, 0, len(farm.AllFarm))
for _, fw := range farm.AllFarm {
available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID))
}
hint := fmt.Sprintf(
"multiple_farm_warehouses — rerun with --farm-warehouse-id=<id> to choose one: %s",
strings.Join(available, " | "),
)
if opts.SkipAmbiguous {
report.Status = "skipped"
report.Reason = hint
} else {
report.Status = "error"
report.Reason = fmt.Sprintf("multiple_farm_warehouses (found %d)", farm.FarmCount)
report.Reason = hint
}
case s.LeftoverQty <= 0:
report.Status = "skipped"
if s.AllocatedQty > 0 {
@@ -477,10 +762,12 @@ func buildTransferPlan(
report.Reason = "zero_on_hand_qty"
}
}
}
if farm.FarmCount == 1 {
fwID := farm.WarehouseID
fwName := farm.WarehouseName
// Attach the chosen farm warehouse to the report row for visibility.
if farm.isResolved() {
fwID := farm.ChosenID
fwName := farm.ChosenName
report.FarmWarehouseID = &fwID
report.FarmWarehouseName = &fwName
}
@@ -490,16 +777,17 @@ func buildTransferPlan(
continue
}
groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.WarehouseID)
groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.ChosenID)
grp := groupMap[groupKey]
if grp == nil {
grp = &transferGroup{
SourceType: s.SourceType,
LocationID: s.LocationID,
LocationName: s.LocationName,
SourceWarehouseID: s.SourceWarehouseID,
SourceWarehouseName: s.SourceWarehouseName,
FarmWarehouseID: farm.WarehouseID,
FarmWarehouseName: farm.WarehouseName,
FarmWarehouseID: farm.ChosenID,
FarmWarehouseName: farm.ChosenName,
}
groupMap[groupKey] = grp
}
@@ -544,7 +832,7 @@ func executeApply(
}
reason := buildTransferReason(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate)
notes := buildStockLogNotes(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate)
notes := buildStockLogNotes(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate, grp.SourceType)
transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{
TransferReason: reason,
@@ -598,7 +886,7 @@ func executeRollback(
for id := range byTransfer {
transferIDs = append(transferIDs, id)
}
// Delete in descending order to minimize downstream conflicts.
// Delete in descending order to unwind downstream dependencies first.
sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] })
var firstErr error
@@ -620,8 +908,7 @@ func executeRollback(
return firstErr
}
// loadRollbackDetails finds all stock transfer rows that were created by the
// given run_id (matched via the transfer reason field).
// loadRollbackDetails finds all transfer rows created by a given run_id.
func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) {
type row struct {
TransferID uint64 `gorm:"column:transfer_id"`
@@ -677,6 +964,22 @@ func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]roll
// ── Helpers ───────────────────────────────────────────────────────────────────
// applyFlagFilter adds an EXISTS subquery that restricts results to products
// carrying at least one flag from opts.FlagFilter. When the filter is empty
// the query is returned unchanged so all products are included.
func applyFlagFilter(q *gorm.DB, opts *commandOptions) *gorm.DB {
if len(opts.FlagFilter) == 0 {
return q
}
return q.Where(`EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_id = p.id
AND f.flagable_type = 'products'
AND UPPER(f.name) IN ?
)`, opts.FlagFilter)
}
func applyLocationFilter(q *gorm.DB, opts *commandOptions, tableAlias string) *gorm.DB {
if opts == nil {
return q
@@ -691,8 +994,8 @@ func applyLocationFilter(q *gorm.DB, opts *commandOptions, tableAlias string) *g
}
}
// buildTransferReason produces a structured string stored in stock_transfers.reason.
// It is used as the rollback lookup key, so must remain stable and parseable.
// buildTransferReason produces the structured string stored in stock_transfers.reason.
// This is the rollback lookup key so its format must remain stable.
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",
@@ -705,21 +1008,19 @@ func buildTransferReason(runID, locationName, srcWarehouse, farmWarehouse string
)
}
// 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 {
// buildStockLogNotes produces a human-readable note for each stock_log entry.
func buildStockLogNotes(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time, sourceType string) string {
kind := "leftover stock transfer from kandang to farm"
if sourceType == sourceTypeFarmConsol {
kind = "farm warehouse consolidation (non-primary farm to chosen farm)"
}
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"),
"[auto] %s | run_id=%s | location=%s | from=%s | to=%s | date=%s",
kind, 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.
// buildRunReasonMatcher returns a SQL LIKE pattern matching all transfers from a run.
func buildRunReasonMatcher(runID string) string {
return fmt.Sprintf("%s|run_id=%s|%%", transferReasonPrefix, strings.TrimSpace(runID))
}
@@ -743,8 +1044,8 @@ func derefString(s *string) string {
return *s
}
// flattenGroups rebuilds reportRows from groups (which carry applied/failed
// status) then appends skipped/error rows from the original slice.
// flattenGroups rebuilds the row list from groups (which carry applied/failed
// status after apply) then appends skipped and error rows from the plan.
func flattenGroups(groups []transferGroup, fallback []transferReportRow) []transferReportRow {
if len(groups) == 0 {
return fallback
@@ -807,7 +1108,7 @@ func renderTransferReport(mode string, rows []transferReportRow, summary applySu
}
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")
fmt.Fprintln(w, "RUN_ID\tSOURCE_TYPE\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 {
@@ -818,8 +1119,9 @@ func renderTransferReport(mode string, rows []transferReportRow, summary applySu
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",
"%s\t%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.SourceType,
row.LocationName,
row.SourceWarehouseName,
derefString(row.FarmWarehouseName),
+393 -165
View File
@@ -11,13 +11,8 @@ import (
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
)
// ── Helpers ───────────────────────────────────────────────────────────────────
// ── Fake executor ─────────────────────────────────────────────────────────────
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
@@ -46,237 +41,467 @@ func (f *fakeSystemTransferExecutor) DeleteSystemTransfer(_ context.Context, id
return nil
}
// ── validateFarmWarehouseMap ──────────────────────────────────────────────────
// ── applyFarmWarehouseOverride ────────────────────────────────────────────────
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"}
func TestApplyOverrideResolvesMultiFarmLocation(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", FarmCount: 1, WarehouseID: 50, WarehouseName: "Gudang Farm Jamali"},
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{
{ID: 50, Name: "Farm A"},
{ID: 51, Name: "Farm B"},
},
},
}
if err := applyFarmWarehouseOverride(farmMap, 51); err != nil {
t.Fatalf("unexpected error: %v", err)
}
info := farmMap[10]
if info.ChosenID != 51 {
t.Errorf("expected ChosenID=51, got %d", info.ChosenID)
}
if info.ChosenName != "Farm B" {
t.Errorf("expected ChosenName=Farm B, got %s", info.ChosenName)
}
if len(info.OtherFarm) != 1 || info.OtherFarm[0].ID != 50 {
t.Errorf("expected OtherFarm=[Farm A], got %+v", info.OtherFarm)
}
}
func TestApplyOverrideErrorsWhenIDNotInAllFarm(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{
{ID: 50, Name: "Farm A"},
{ID: 51, Name: "Farm B"},
},
},
}
err := applyFarmWarehouseOverride(farmMap, 99)
if err == nil {
t.Fatal("expected error for unknown warehouse id, got nil")
}
if !strings.Contains(err.Error(), "99") {
t.Errorf("error should mention the invalid id, got: %v", err)
}
if !strings.Contains(err.Error(), "Farm A") || !strings.Contains(err.Error(), "Farm B") {
t.Errorf("error should list available warehouses, got: %v", err)
}
}
func TestApplyOverrideIgnoresSingleFarmLocations(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Jamali",
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
ChosenID: 50,
ChosenName: "Farm A",
},
}
// Override ID 50 is present, but there is only 1 farm; the function should
// not touch this location (no OtherFarm to populate).
if err := applyFarmWarehouseOverride(farmMap, 50); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(farmMap[10].OtherFarm) != 0 {
t.Errorf("expected no OtherFarm for single-farm location, got %+v", farmMap[10].OtherFarm)
}
}
func TestApplyOverrideNoopWhenZero(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{
{ID: 50, Name: "Farm A"},
{ID: 51, Name: "Farm B"},
},
},
}
if err := applyFarmWarehouseOverride(farmMap, 0); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if farmMap[10].ChosenID != 0 {
t.Errorf("expected ChosenID unchanged (0), got %d", farmMap[10].ChosenID)
}
}
// ── listUnresolvedLocations ───────────────────────────────────────────────────
func TestListUnresolvedLocationsReturnsOnlyAmbiguous(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
1: {LocationID: 1, LocationName: "Jamali", AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50},
2: {
LocationID: 2,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
// ChosenID = 0: unresolved
},
3: {LocationID: 3, LocationName: "Tamansari", AllFarm: nil}, // no farm at all, not an error here
}
msgs := listUnresolvedLocations(farmMap)
if len(msgs) != 1 {
t.Fatalf("expected 1 unresolved message, got %d: %v", len(msgs), msgs)
}
if !strings.Contains(msgs[0], "Cijangkar") {
t.Errorf("message should name the ambiguous location, got: %s", msgs[0])
}
if !strings.Contains(msgs[0], "Farm X") || !strings.Contains(msgs[0], "Farm Y") {
t.Errorf("message should list available warehouses, got: %s", msgs[0])
}
}
// ── buildTransferPlan — kandang source ───────────────────────────────────────
func TestBuildPlanKandangEligibleGroupedByWarehousePair(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
}
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},
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "K1", ProductID: 2, ProductName: "OVK B", OnHandQty: 50, AllocatedQty: 10, LeftoverQty: 40, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(reportRows) != 2 {
t.Fatalf("expected 2 report rows, got %d", len(reportRows))
if len(groups) != 1 || len(groups[0].Rows) != 2 {
t.Fatalf("expected 1 group with 2 rows, got %d groups", len(groups))
}
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])
if groups[0].SourceType != sourceTypeKandang {
t.Errorf("expected group source type kandang_to_farm, got %s", groups[0].SourceType)
}
for _, row := range reportRows {
if row.Status != "eligible" {
t.Errorf("expected eligible, got %s for %s", row.Status, row.ProductName)
}
}
if reportRows[1].Qty != 40 {
t.Errorf("expected leftover qty 40 for OVK B, got %.3f", reportRows[1].Qty)
}
}
func TestBuildTransferPlanSkipsMissingFarmWarehouse(t *testing.T) {
opts := &commandOptions{RunID: "product-farm-transfer-test"}
func TestBuildPlanSkipsMissingFarmWarehouse(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", FarmCount: 0},
10: {LocationID: 10, LocationName: "Jamali", AllFarm: nil},
}
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "Gudang K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100},
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 {
t.Fatalf("expected no transfer groups, got %d", len(groups))
t.Fatalf("expected no 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)
t.Errorf("unexpected: %s / %s", reportRows[0].Status, reportRows[0].Reason)
}
}
func TestBuildTransferPlanMarksErrorForMultipleFarmWarehouses(t *testing.T) {
opts := &commandOptions{RunID: "product-farm-transfer-test"}
func TestBuildPlanErrorForUnresolvedMultiFarmWarehouse(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Cijangkar", FarmCount: 2},
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
// ChosenID = 0: unresolved
},
}
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, SourceWarehouseName: "Gudang K2", ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200},
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 {
t.Fatalf("expected no transfer groups, got %d", len(groups))
t.Fatalf("expected no groups, got %d", len(groups))
}
if reportRows[0].Status != "error" {
t.Errorf("expected error status for multiple farm warehouses, got %s", reportRows[0].Status)
t.Errorf("expected error status, got %s", reportRows[0].Status)
}
if !strings.Contains(reportRows[0].Reason, "multiple_farm_warehouses") {
t.Errorf("unexpected reason: %s", reportRows[0].Reason)
t.Errorf("reason should mention multiple_farm_warehouses, got: %s", reportRows[0].Reason)
}
// The error message must list the available warehouses so the operator knows
// which --farm-warehouse-id to use.
if !strings.Contains(reportRows[0].Reason, "Farm X") || !strings.Contains(reportRows[0].Reason, "Farm Y") {
t.Errorf("reason should list available warehouses, got: %s", reportRows[0].Reason)
}
}
func TestBuildTransferPlanSkipsFullyAllocatedStock(t *testing.T) {
opts := &commandOptions{RunID: "product-farm-transfer-test"}
func TestBuildPlanSkipsFullyAllocatedKandangStock(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", FarmCount: 1, WarehouseID: 50, WarehouseName: "Gudang Farm Jamali"},
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
}
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},
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeKandang},
{LocationID: 10, SourceWarehouseID: 20, ProductID: 2, ProductName: "OVK B", OnHandQty: 80, AllocatedQty: 30, LeftoverQty: 50, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
_, 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 groups[0].Rows[0].ProductName != "OVK B" {
t.Errorf("expected only OVK B to be eligible, got %s", groups[0].Rows[0].ProductName)
}
}
func TestBuildPlanSkipAmbiguousDowngradesErrorToSkipped(t *testing.T) {
opts := &commandOptions{RunID: "test-run", SkipAmbiguous: true}
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
// ChosenID = 0: unresolved
},
11: {
LocationID: 11,
LocationName: "Jamali",
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
ChosenID: 50,
ChosenName: "Farm A",
},
}
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200, SourceType: sourceTypeKandang},
{LocationID: 11, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
// Ambiguous location must be skipped, not error.
ambiguous := reportRows[0]
if ambiguous.LocationName != "Cijangkar" {
t.Fatalf("expected first row to be Cijangkar, got %s", ambiguous.LocationName)
}
if ambiguous.Status != "skipped" {
t.Errorf("expected skipped with --skip-ambiguous, got %s", ambiguous.Status)
}
if !strings.Contains(ambiguous.Reason, "multiple_farm_warehouses") {
t.Errorf("reason should still explain the cause, got: %s", ambiguous.Reason)
}
// Unambiguous location must still be eligible and grouped.
if len(groups) != 1 || groups[0].LocationName != "Jamali" {
t.Errorf("expected 1 group for Jamali, got %d groups", len(groups))
}
}
// ── applyFlagFilter (unit-level, via buildTransferPlan) ───────────────────────
// applyFlagFilter is a DB-level filter so we test its effect indirectly: the
// flag filter is applied before rows reach buildTransferPlan, so we simulate
// by only passing stock rows that the query would have returned.
// The real guard is that loadKandangLeftoverStocks receives the filtered set.
// Here we verify that buildTransferPlan itself is agnostic to the filter and
// simply processes whatever rows it is given.
func TestBuildPlanOnlyTransfersRowsPassedToIt(t *testing.T) {
opts := &commandOptions{RunID: "test-run", FlagFilter: []string{"PAKAN"}}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
}
// Simulate: only PAKAN products survived the DB filter; OVK was excluded.
stocks := []kandangStockRow{
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan Broiler", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
}
_, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 1 || len(groups[0].Rows) != 1 {
t.Fatalf("expected 1 group with 1 row, got %d groups", len(groups))
}
if groups[0].Rows[0].ProductName != "Pakan Broiler" {
t.Errorf("unexpected product: %s", groups[0].Rows[0].ProductName)
}
}
// ── buildTransferPlan — farm_consolidation source ────────────────────────────
func TestBuildPlanFarmConsolidationCreatesOwnGroup(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
// Location 10 has 2 farm warehouses; Farm B (id=51) was chosen, Farm A
// (id=50) is OtherFarm whose stocks need consolidating.
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}, {ID: 51, Name: "Farm B"}},
ChosenID: 51,
ChosenName: "Farm B",
OtherFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
},
}
// Extra farm stock from Farm A + normal kandang stock.
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 20, SourceWarehouseName: "Kandang K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 50, SourceWarehouseName: "Farm A", ProductID: 2, ProductName: "OVK B", OnHandQty: 60, LeftoverQty: 60, SourceType: sourceTypeFarmConsol},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 2 {
t.Fatalf("expected 2 groups (one per source warehouse), got %d", len(groups))
}
if len(reportRows) != 2 {
t.Fatalf("expected 2 report rows, got %d", len(reportRows))
}
groupsBySource := make(map[uint]*transferGroup, 2)
for i := range groups {
groupsBySource[groups[i].SourceWarehouseID] = &groups[i]
}
kandangGroup := groupsBySource[20]
if kandangGroup == nil {
t.Fatal("expected a group with SourceWarehouseID=20")
}
if kandangGroup.SourceType != sourceTypeKandang {
t.Errorf("expected kandang_to_farm group, got %s", kandangGroup.SourceType)
}
if kandangGroup.FarmWarehouseID != 51 {
t.Errorf("expected kandang group to target Farm B (51), got %d", kandangGroup.FarmWarehouseID)
}
consolGroup := groupsBySource[50]
if consolGroup == nil {
t.Fatal("expected a consolidation group with SourceWarehouseID=50")
}
if consolGroup.SourceType != sourceTypeFarmConsol {
t.Errorf("expected farm_consolidation group, got %s", consolGroup.SourceType)
}
if consolGroup.FarmWarehouseID != 51 {
t.Errorf("expected consolidation group to target Farm B (51), got %d", consolGroup.FarmWarehouseID)
}
}
func TestBuildPlanFarmConsolidationSkipsZeroLeftover(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50}, {ID: 51}}, ChosenID: 51, ChosenName: "Farm B", OtherFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}},
}
stocks := []kandangStockRow{
{LocationID: 10, SourceWarehouseID: 50, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeFarmConsol},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 {
t.Fatalf("expected no groups, got %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)
t.Errorf("expected skipped, got %s", reportRows[0].Status)
}
}
// ── executeApply ──────────────────────────────────────────────────────────────
func TestExecuteApplyCreatesTransfersWithTaggedReasonAndNotes(t *testing.T) {
func TestExecuteApplyTagsReasonAndNotesWithSourceType(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,
}
opts := &commandOptions{RunID: "run-apply", TransferDate: date, ActorID: 99}
groups := []transferGroup{
{
LocationID: 10,
SourceType: sourceTypeKandang,
LocationName: "Jamali",
SourceWarehouseID: 20,
SourceWarehouseName: "Gudang K1",
SourceWarehouseName: "K1",
FarmWarehouseID: 50,
FarmWarehouseName: "Gudang Farm Jamali",
Rows: []*transferReportRow{
{ProductID: 1, ProductName: "Pakan A", Qty: 100},
{ProductID: 2, ProductName: "OVK B", Qty: 40},
},
FarmWarehouseName: "Farm A",
Rows: []*transferReportRow{{ProductID: 1, ProductName: "Pakan A", Qty: 100}},
},
{
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"),
SourceType: sourceTypeFarmConsol,
LocationName: "Cijangkar",
SourceWarehouseID: 60,
SourceWarehouseName: "Farm X",
FarmWarehouseID: 61,
FarmWarehouseName: "Farm Y",
Rows: []*transferReportRow{{ProductID: 2, ProductName: "OVK B", Qty: 40}},
},
}
executor := &fakeSystemTransferExecutor{}
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 summary.GroupsApplied != 2 {
t.Errorf("expected 2 groups applied, got %d", summary.GroupsApplied)
}
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)
// Both requests must carry the run_id in the reason for rollback to work.
for i, req := range executor.createRequests {
if !strings.Contains(req.TransferReason, "run_id=run-apply") {
t.Errorf("request %d reason missing run_id: %s", i, req.TransferReason)
}
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)
// Notes for farm_consolidation should be distinct from kandang_to_farm.
if !strings.Contains(executor.createRequests[0].StockLogNotes, "kandang to farm") {
t.Errorf("kandang group notes should say 'kandang to farm', got: %s", executor.createRequests[0].StockLogNotes)
}
if !strings.Contains(notes, "Jamali") {
t.Errorf("stock log notes must contain location name, got: %s", notes)
if !strings.Contains(executor.createRequests[1].StockLogNotes, "consolidation") {
t.Errorf("consolidation group notes should say 'consolidation', got: %s", executor.createRequests[1].StockLogNotes)
}
}
func TestExecuteApplyCreatesTransferWithCorrectProductsAndRecordsTransferID(t *testing.T) {
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
opts := &commandOptions{RunID: "run-1", TransferDate: date, ActorID: 1}
row1 := &transferReportRow{ProductID: 1, ProductName: "Pakan A", Qty: 100}
row2 := &transferReportRow{ProductID: 2, ProductName: "OVK B", Qty: 40}
groups := []transferGroup{
{
SourceType: sourceTypeKandang, SourceWarehouseID: 20, FarmWarehouseID: 50,
Rows: []*transferReportRow{row1, row2},
},
}
if executor.createRequests[0].MovementNumber != "" {
t.Errorf("movement number should be empty so the service generates one, got: %q", executor.createRequests[0].MovementNumber)
executor := &fakeSystemTransferExecutor{
createResponses: []*entity.StockTransfer{{Id: 1001, MovementNumber: "PND-LTI-1001"}},
}
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)
_, err := executeApply(context.Background(), executor, opts, groups)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if groups[1].Rows[0].Status != "failed" {
t.Errorf("second group row must be failed: %+v", groups[1].Rows[0])
if row1.Status != "applied" || row2.Status != "applied" {
t.Errorf("both rows should be applied: %s, %s", row1.Status, row2.Status)
}
if groups[0].Rows[0].TransferID == nil || *groups[0].Rows[0].TransferID != 1001 {
t.Errorf("first group must carry transfer id 1001")
if row1.TransferID == nil || *row1.TransferID != 1001 {
t.Errorf("expected transfer id 1001, got %+v", row1.TransferID)
}
if row1.MovementNumber == nil || *row1.MovementNumber != "PND-LTI-1001" {
t.Errorf("expected movement number PND-LTI-1001, got %+v", row1.MovementNumber)
}
// Verify both products were included in the create request.
if len(executor.createRequests[0].Products) != 2 {
t.Errorf("expected 2 products in request, got %d", len(executor.createRequests[0].Products))
}
}
// ── executeRollback ───────────────────────────────────────────────────────────
func TestExecuteRollbackDeletesInDescendingOrderAndMarksStatuses(t *testing.T) {
func TestExecuteRollbackDeletesDescendingAndMarksStatuses(t *testing.T) {
executor := &fakeSystemTransferExecutor{
deleteErrors: map[uint]error{
200: errors.New("stock already consumed downstream"),
},
deleteErrors: map[uint]error{200: errors.New("already consumed")},
}
rows := []rollbackDetailRow{
{TransferID: 100, ProductName: "Pakan A"},
@@ -285,24 +510,17 @@ func TestExecuteRollbackDeletesInDescendingOrderAndMarksStatuses(t *testing.T) {
}
err := executeRollback(context.Background(), executor, rows, 99)
if err == nil {
t.Fatal("expected rollback error for transfer 200")
if err == nil || !strings.Contains(err.Error(), "already consumed") {
t.Fatalf("expected error for transfer 200, got: %v", err)
}
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)
t.Errorf("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])
t.Errorf("transfer 200 row must be failed: %+v", rows[1])
}
}
@@ -315,14 +533,12 @@ func TestExecuteRollbackRequiresActorID(t *testing.T) {
// ── buildTransferReason / buildRunReasonMatcher ───────────────────────────────
func TestBuildTransferReasonIsMatchedByRunReasonMatcher(t *testing.T) {
func TestBuildTransferReasonMatchesRunReasonMatcher(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, "%")
needle := strings.TrimSuffix(buildRunReasonMatcher(runID), "%")
if !strings.HasPrefix(reason, needle) {
t.Errorf("reason %q does not match matcher prefix %q", reason, needle)
}
@@ -330,17 +546,29 @@ func TestBuildTransferReasonIsMatchedByRunReasonMatcher(t *testing.T) {
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).
// prefix + 5 key=value segments = 6 parts
if len(parts) != 6 {
t.Errorf("expected 6 pipe segments, got %d: %v", len(parts), parts)
t.Errorf("expected 6 pipe-separated segments, got %d: %v", len(parts), parts)
}
}
func TestBuildStockLogNotesContainsSourceTypeHint(t *testing.T) {
date := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
kandangNote := buildStockLogNotes("r", "Loc", "Src", "Dst", date, sourceTypeKandang)
consolNote := buildStockLogNotes("r", "Loc", "Src", "Dst", date, sourceTypeFarmConsol)
if !strings.Contains(kandangNote, "kandang to farm") {
t.Errorf("kandang note should mention 'kandang to farm': %s", kandangNote)
}
if !strings.Contains(consolNote, "consolidation") {
t.Errorf("consolidation note should mention 'consolidation': %s", consolNote)
}
}
// ── summarizeReport ───────────────────────────────────────────────────────────
func TestSummarizeReportCountsCorrectly(t *testing.T) {
func TestSummarizeReportCountsAllStatuses(t *testing.T) {
rows := []transferReportRow{
{Status: "eligible"},
{Status: "applied"},
@@ -368,6 +596,6 @@ func TestSummarizeReportCountsCorrectly(t *testing.T) {
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)
t.Errorf("unexpected group counts: %+v", s)
}
}