mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 05:21:57 +00:00
374 lines
14 KiB
Go
374 lines
14 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"
|
|
)
|
|
|
|
// ── 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)
|
|
}
|
|
}
|