mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 05:21:57 +00:00
602 lines
23 KiB
Go
602 lines
23 KiB
Go
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"
|
|
)
|
|
|
|
// ── Fake executor ─────────────────────────────────────────────────────────────
|
|
|
|
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
|
|
}
|
|
|
|
// ── applyFarmWarehouseOverride ────────────────────────────────────────────────
|
|
|
|
func TestApplyOverrideResolvesMultiFarmLocation(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, 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: "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(groups) != 1 || len(groups[0].Rows) != 2 {
|
|
t.Fatalf("expected 1 group with 2 rows, got %d groups", len(groups))
|
|
}
|
|
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 TestBuildPlanSkipsMissingFarmWarehouse(t *testing.T) {
|
|
opts := &commandOptions{RunID: "test-run"}
|
|
farmMap := map[uint]farmWarehouseInfo{
|
|
10: {LocationID: 10, LocationName: "Jamali", AllFarm: nil},
|
|
}
|
|
stocks := []kandangStockRow{
|
|
{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 groups, got %d", len(groups))
|
|
}
|
|
if reportRows[0].Status != "skipped" || reportRows[0].Reason != "missing_farm_warehouse" {
|
|
t.Errorf("unexpected: %s / %s", reportRows[0].Status, reportRows[0].Reason)
|
|
}
|
|
}
|
|
|
|
func TestBuildPlanErrorForUnresolvedMultiFarmWarehouse(t *testing.T) {
|
|
opts := &commandOptions{RunID: "test-run"}
|
|
farmMap := map[uint]farmWarehouseInfo{
|
|
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, 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 groups, got %d", len(groups))
|
|
}
|
|
if reportRows[0].Status != "error" {
|
|
t.Errorf("expected error status, got %s", reportRows[0].Status)
|
|
}
|
|
if !strings.Contains(reportRows[0].Reason, "multiple_farm_warehouses") {
|
|
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 TestBuildPlanSkipsFullyAllocatedKandangStock(t *testing.T) {
|
|
opts := &commandOptions{RunID: "test-run"}
|
|
farmMap := map[uint]farmWarehouseInfo{
|
|
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
|
|
}
|
|
stocks := []kandangStockRow{
|
|
{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},
|
|
}
|
|
|
|
_, 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 skipped, got %s", reportRows[0].Status)
|
|
}
|
|
}
|
|
|
|
// ── executeApply ──────────────────────────────────────────────────────────────
|
|
|
|
func TestExecuteApplyTagsReasonAndNotesWithSourceType(t *testing.T) {
|
|
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
|
|
opts := &commandOptions{RunID: "run-apply", TransferDate: date, ActorID: 99}
|
|
|
|
groups := []transferGroup{
|
|
{
|
|
SourceType: sourceTypeKandang,
|
|
LocationName: "Jamali",
|
|
SourceWarehouseID: 20,
|
|
SourceWarehouseName: "K1",
|
|
FarmWarehouseID: 50,
|
|
FarmWarehouseName: "Farm A",
|
|
Rows: []*transferReportRow{{ProductID: 1, ProductName: "Pakan A", Qty: 100}},
|
|
},
|
|
{
|
|
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.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))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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(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},
|
|
},
|
|
}
|
|
|
|
executor := &fakeSystemTransferExecutor{
|
|
createResponses: []*entity.StockTransfer{{Id: 1001, MovementNumber: "PND-LTI-1001"}},
|
|
}
|
|
|
|
_, err := executeApply(context.Background(), executor, opts, groups)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if row1.Status != "applied" || row2.Status != "applied" {
|
|
t.Errorf("both rows should be applied: %s, %s", row1.Status, row2.Status)
|
|
}
|
|
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 TestExecuteRollbackDeletesDescendingAndMarksStatuses(t *testing.T) {
|
|
executor := &fakeSystemTransferExecutor{
|
|
deleteErrors: map[uint]error{200: errors.New("already consumed")},
|
|
}
|
|
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 || !strings.Contains(err.Error(), "already consumed") {
|
|
t.Fatalf("expected error for transfer 200, got: %v", err)
|
|
}
|
|
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.Errorf("transfer 100 rows must be rolled_back: %+v", rows)
|
|
}
|
|
if rows[1].Status != "failed" {
|
|
t.Errorf("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 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)
|
|
|
|
needle := strings.TrimSuffix(buildRunReasonMatcher(runID), "%")
|
|
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))
|
|
parts := strings.Split(reason, "|")
|
|
// prefix + 5 key=value segments = 6 parts
|
|
if len(parts) != 6 {
|
|
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 TestSummarizeReportCountsAllStatuses(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: %+v", s)
|
|
}
|
|
}
|