package service import ( "context" "strings" "testing" "time" "github.com/glebarez/sqlite" "github.com/go-playground/validator/v10" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) func TestCreateSystemTransferCreatesAuditableMovement(t *testing.T) { db := setupSystemTransferTestDB(t) svc, fifoStub := newSystemTransferTestService(t, db) transferDate := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC) result, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{ TransferReason: "EGG_FARM_CUTOVER|run_id=test-1|location=Jamali|cutover_date=2026-04-07", TransferDate: transferDate, SourceWarehouseID: 1, DestinationWarehouseID: 2, Products: []SystemTransferProduct{ {ProductID: 8, ProductQty: 50}, }, ActorID: 99, MovementNumber: "PND-LTI-TEST-0001", StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-1|location=Jamali|cutover_date=2026-04-07", }) if err != nil { t.Fatalf("expected no error, got %v", err) } if result == nil { t.Fatal("expected transfer result") } if result.MovementNumber != "PND-LTI-TEST-0001" { t.Fatalf("expected movement number to be preserved, got %s", result.MovementNumber) } var transfer entity.StockTransfer if err := db.WithContext(context.Background()).First(&transfer, result.Id).Error; err != nil { t.Fatalf("failed to load created transfer: %v", err) } var detail entity.StockTransferDetail if err := db.WithContext(context.Background()). Where("stock_transfer_id = ?", transfer.Id). First(&detail).Error; err != nil { t.Fatalf("failed to load transfer detail: %v", err) } if detail.UsageQty != 50 { t.Fatalf("expected usage qty 50, got %v", detail.UsageQty) } if detail.TotalQty != 50 { t.Fatalf("expected total qty 50, got %v", detail.TotalQty) } if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID != 10 { t.Fatalf("expected source product warehouse 10, got %+v", detail.SourceProductWarehouseID) } if detail.DestProductWarehouseID == nil { t.Fatal("expected destination product warehouse to be created") } var destPW entity.ProductWarehouse if err := db.WithContext(context.Background()). First(&destPW, *detail.DestProductWarehouseID).Error; err != nil { t.Fatalf("failed to load destination product warehouse: %v", err) } if destPW.WarehouseId != 2 { t.Fatalf("expected destination warehouse id 2, got %d", destPW.WarehouseId) } if destPW.ProductId != 8 { t.Fatalf("expected destination product id 8, got %d", destPW.ProductId) } if destPW.ProjectFlockKandangId != nil { t.Fatalf("expected destination product warehouse to stay shared, got %+v", destPW.ProjectFlockKandangId) } var stockLogs []entity.StockLog if err := db.WithContext(context.Background()). Order("id ASC"). Find(&stockLogs).Error; err != nil { t.Fatalf("failed to load stock logs: %v", err) } if len(stockLogs) != 3 { t.Fatalf("expected 3 stock logs (seed + out + in), got %d", len(stockLogs)) } if stockLogs[1].ProductWarehouseId != 10 || stockLogs[1].Decrease != 50 || stockLogs[1].Stock != 0 { t.Fatalf("unexpected source stock log after transfer: %+v", stockLogs[1]) } if stockLogs[2].ProductWarehouseId != destPW.Id || stockLogs[2].Increase != 50 || stockLogs[2].Stock != 50 { t.Fatalf("unexpected destination stock log after transfer: %+v", stockLogs[2]) } if len(fifoStub.reflowCalls) != 2 { t.Fatalf("expected 2 reflow calls, got %d", len(fifoStub.reflowCalls)) } if fifoStub.reflowCalls[0].ProductWarehouseID != 10 { t.Fatalf("expected first reflow on source pw 10, got %d", fifoStub.reflowCalls[0].ProductWarehouseID) } if fifoStub.reflowCalls[1].ProductWarehouseID != destPW.Id { t.Fatalf("expected second reflow on destination pw %d, got %d", destPW.Id, fifoStub.reflowCalls[1].ProductWarehouseID) } } func TestDeleteSystemTransferRollsBackTransferWhenUnused(t *testing.T) { db := setupSystemTransferTestDB(t) svc, fifoStub := newSystemTransferTestService(t, db) created, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{ TransferReason: "EGG_FARM_CUTOVER|run_id=test-rollback|location=Jamali|cutover_date=2026-04-07", TransferDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC), SourceWarehouseID: 1, DestinationWarehouseID: 2, Products: []SystemTransferProduct{{ProductID: 8, ProductQty: 50}}, ActorID: 99, MovementNumber: "PND-LTI-TEST-ROLLBACK", StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-rollback|location=Jamali|cutover_date=2026-04-07", }) if err != nil { t.Fatalf("failed to create transfer: %v", err) } var detail entity.StockTransferDetail if err := db.WithContext(context.Background()). Where("stock_transfer_id = ?", created.Id). First(&detail).Error; err != nil { t.Fatalf("failed to load transfer detail: %v", err) } fifoStub.rollbackReleasedQty[detail.Id] = detail.UsageQty if err := svc.DeleteSystemTransfer(context.Background(), uint(created.Id), 99); err != nil { t.Fatalf("expected delete to succeed, got %v", err) } var deletedTransfer entity.StockTransfer if err := db.WithContext(context.Background()).Unscoped().First(&deletedTransfer, created.Id).Error; err != nil { t.Fatalf("failed to load deleted transfer: %v", err) } if deletedTransfer.DeletedAt == nil { t.Fatal("expected transfer to be soft deleted") } var deletedDetail entity.StockTransferDetail if err := db.WithContext(context.Background()).Unscoped().First(&deletedDetail, detail.Id).Error; err != nil { t.Fatalf("failed to load deleted transfer detail: %v", err) } if deletedDetail.DeletedAt == nil { t.Fatal("expected transfer detail to be soft deleted") } var stockLogs []entity.StockLog if err := db.WithContext(context.Background()). Order("id ASC"). Find(&stockLogs).Error; err != nil { t.Fatalf("failed to load stock logs: %v", err) } if len(stockLogs) != 5 { t.Fatalf("expected 5 stock logs (seed + create out/in + delete in/out), got %d", len(stockLogs)) } if stockLogs[3].ProductWarehouseId != 10 || stockLogs[3].Increase != 50 || stockLogs[3].Stock != 50 { t.Fatalf("unexpected rollback source stock log: %+v", stockLogs[3]) } if stockLogs[4].Decrease != 50 || stockLogs[4].Stock != 0 { t.Fatalf("unexpected rollback destination stock log: %+v", stockLogs[4]) } if len(fifoStub.rollbackCalls) != 1 { t.Fatalf("expected 1 rollback call, got %d", len(fifoStub.rollbackCalls)) } if len(fifoStub.reflowCalls) != 3 { t.Fatalf("expected 3 reflow calls (2 create + 1 delete), got %d", len(fifoStub.reflowCalls)) } } func TestDeleteSystemTransferRejectsRollbackWhenDownstreamConsumptionExists(t *testing.T) { db := setupSystemTransferTestDB(t) svc, fifoStub := newSystemTransferTestService(t, db) created, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{ TransferReason: "EGG_FARM_CUTOVER|run_id=test-guard|location=Jamali|cutover_date=2026-04-07", TransferDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC), SourceWarehouseID: 1, DestinationWarehouseID: 2, Products: []SystemTransferProduct{{ProductID: 8, ProductQty: 50}}, ActorID: 99, MovementNumber: "PND-LTI-TEST-GUARD", StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-guard|location=Jamali|cutover_date=2026-04-07", }) if err != nil { t.Fatalf("failed to create transfer: %v", err) } var detail entity.StockTransferDetail if err := db.WithContext(context.Background()). Where("stock_transfer_id = ?", created.Id). First(&detail).Error; err != nil { t.Fatalf("failed to load transfer detail: %v", err) } if err := db.Exec(` INSERT INTO stock_allocations ( id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, allocation_purpose, status, function_code, flag_group_code, deleted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) `, 1, *detail.DestProductWarehouseID, fifo.StockableKeyStockTransferIn.String(), detail.Id, fifo.UsableKeyRecordingStock.String(), 9001, 10, entity.StockAllocationPurposeConsume, entity.StockAllocationStatusActive, "RECORDING_STOCK_OUT", "EGG").Error; err != nil { t.Fatalf("failed to seed stock allocation: %v", err) } err = svc.DeleteSystemTransfer(context.Background(), uint(created.Id), 99) if err == nil { t.Fatal("expected delete to be blocked by downstream consumption") } if !strings.Contains(err.Error(), "tidak dapat dihapus") { t.Fatalf("expected downstream guard error, got %v", err) } if len(fifoStub.rollbackCalls) != 0 { t.Fatalf("expected rollback not to be called, got %d calls", len(fifoStub.rollbackCalls)) } var transfer entity.StockTransfer if err := db.WithContext(context.Background()).First(&transfer, created.Id).Error; err != nil { t.Fatalf("failed to reload transfer: %v", err) } if transfer.DeletedAt != nil { t.Fatal("expected transfer to remain active after guard failure") } } type fifoStockV2Stub struct { reflowCalls []commonSvc.FifoStockV2ReflowRequest rollbackCalls []commonSvc.FifoStockV2RollbackRequest rollbackReleasedQty map[uint64]float64 } func (f *fifoStockV2Stub) Gather(ctx context.Context, req commonSvc.FifoStockV2GatherRequest) ([]commonSvc.FifoStockV2GatherRow, error) { return nil, nil } func (f *fifoStockV2Stub) Allocate(ctx context.Context, req commonSvc.FifoStockV2AllocateRequest) (*commonSvc.FifoStockV2AllocateResult, error) { return nil, nil } func (f *fifoStockV2Stub) Rollback(ctx context.Context, req commonSvc.FifoStockV2RollbackRequest) (*commonSvc.FifoStockV2RollbackResult, error) { f.rollbackCalls = append(f.rollbackCalls, req) return &commonSvc.FifoStockV2RollbackResult{ ReleasedQty: f.rollbackReleasedQty[uint64(req.Usable.ID)], }, nil } func (f *fifoStockV2Stub) Reflow(ctx context.Context, req commonSvc.FifoStockV2ReflowRequest) (*commonSvc.FifoStockV2ReflowResult, error) { f.reflowCalls = append(f.reflowCalls, req) return &commonSvc.FifoStockV2ReflowResult{}, nil } func (f *fifoStockV2Stub) Recalculate(ctx context.Context, req commonSvc.FifoStockV2RecalculateRequest) (*commonSvc.FifoStockV2RecalculateResult, error) { return &commonSvc.FifoStockV2RecalculateResult{}, nil } func newSystemTransferTestService(t *testing.T, db *gorm.DB) (TransferService, *fifoStockV2Stub) { t.Helper() fifoStub := &fifoStockV2Stub{rollbackReleasedQty: make(map[uint64]float64)} return NewTransferService( validator.New(), rTransfer.NewStockTransferRepository(db), rTransfer.NewStockTransferDetailRepository(db), rTransfer.NewStockTransferDeliveryRepository(db), rTransfer.NewStockTransferDeliveryItemRepository(db), rStockLogs.NewStockLogRepository(db), rProductWarehouse.NewProductWarehouseRepository(db), nil, rWarehouse.NewWarehouseRepository(db), nil, nil, nil, fifoStub, nil, ), fifoStub } func setupSystemTransferTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatalf("failed opening sqlite db: %v", err) } statements := []string{ `CREATE TABLE warehouses ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL, area_id INTEGER NOT NULL DEFAULT 1, location_id INTEGER NULL, kandang_id INTEGER NULL, created_by INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, deleted_at TIMESTAMP NULL )`, `CREATE TABLE product_categories ( id INTEGER PRIMARY KEY, name TEXT NULL, code TEXT NOT NULL, created_by INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, deleted_at TIMESTAMP NULL )`, `CREATE TABLE products ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, brand TEXT NOT NULL DEFAULT '', sku TEXT NULL, uom_id INTEGER NOT NULL DEFAULT 1, product_category_id INTEGER NULL, product_price NUMERIC NOT NULL DEFAULT 0, selling_price NUMERIC NULL, tax NUMERIC NULL, expiry_period INTEGER NULL, created_by INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, deleted_at TIMESTAMP NULL, is_visible BOOLEAN NOT NULL DEFAULT 1 )`, `CREATE TABLE product_warehouses ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, warehouse_id INTEGER NOT NULL, project_flock_kandang_id INTEGER NULL, qty NUMERIC NOT NULL DEFAULT 0 )`, `CREATE TABLE flags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, flagable_id INTEGER NOT NULL, flagable_type TEXT NOT NULL )`, `CREATE TABLE fifo_stock_v2_flag_groups ( code TEXT PRIMARY KEY, is_active BOOLEAN NOT NULL )`, `CREATE TABLE fifo_stock_v2_flag_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, flag_name TEXT NOT NULL, flag_group_code TEXT NOT NULL, is_active BOOLEAN NOT NULL )`, `CREATE TABLE fifo_stock_v2_route_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, lane TEXT NOT NULL, function_code TEXT NOT NULL, source_table TEXT NOT NULL, flag_group_code TEXT NOT NULL, legacy_type_key TEXT NULL, allow_pending_default BOOLEAN NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL )`, `CREATE TABLE fifo_stock_v2_overconsume_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, lane TEXT NOT NULL, flag_group_code TEXT NULL, function_code TEXT NULL, allow_overconsume BOOLEAN NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT 1, priority INTEGER NOT NULL DEFAULT 1 )`, `CREATE TABLE stock_transfers ( id INTEGER PRIMARY KEY AUTOINCREMENT, movement_number TEXT NOT NULL, from_warehouse_id INTEGER NOT NULL, to_warehouse_id INTEGER NOT NULL, transfer_date TIMESTAMP NOT NULL, reason TEXT, created_by INTEGER NOT NULL, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, deleted_at TIMESTAMP NULL )`, `CREATE TABLE stock_transfer_details ( id INTEGER PRIMARY KEY AUTOINCREMENT, stock_transfer_id INTEGER NOT NULL, product_id INTEGER NOT NULL, source_product_warehouse_id INTEGER NULL, usage_qty NUMERIC NOT NULL DEFAULT 0, pending_qty NUMERIC NOT NULL DEFAULT 0, dest_product_warehouse_id INTEGER NULL, total_qty NUMERIC NOT NULL DEFAULT 0, total_used NUMERIC NOT NULL DEFAULT 0, expense_nonstock_id INTEGER NULL, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, deleted_at TIMESTAMP NULL )`, `CREATE TABLE stock_transfer_deliveries ( id INTEGER PRIMARY KEY AUTOINCREMENT, stock_transfer_id INTEGER NOT NULL, supplier_id INTEGER NULL, vehicle_plate TEXT NULL, driver_name TEXT NULL, shipping_cost_item NUMERIC NULL, shipping_cost_total NUMERIC NULL, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, deleted_at TIMESTAMP NULL )`, `CREATE TABLE stock_transfer_delivery_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, stock_transfer_delivery_id INTEGER NOT NULL, stock_transfer_detail_id INTEGER NOT NULL, quantity NUMERIC NOT NULL DEFAULT 0, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, deleted_at TIMESTAMP NULL )`, `CREATE TABLE stock_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_warehouse_id INTEGER NOT NULL, created_by INTEGER NOT NULL, increase NUMERIC NOT NULL DEFAULT 0, decrease NUMERIC NOT NULL DEFAULT 0, stock NUMERIC NOT NULL DEFAULT 0, loggable_type TEXT NOT NULL, loggable_id INTEGER NOT NULL, notes TEXT NULL, created_at TIMESTAMP NULL )`, `CREATE TABLE stock_allocations ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_warehouse_id INTEGER NOT NULL, stockable_type TEXT NOT NULL, stockable_id INTEGER NOT NULL, usable_type TEXT NOT NULL, usable_id INTEGER NOT NULL, qty NUMERIC NOT NULL DEFAULT 0, allocation_purpose TEXT NOT NULL, status TEXT NOT NULL, function_code TEXT NULL, flag_group_code TEXT NULL, deleted_at TIMESTAMP NULL )`, `INSERT INTO warehouses (id, name, type, area_id, location_id, kandang_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Gudang Kandang Legacy', 'LOKASI', 1, 16, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), (2, 'Gudang Farm Jamali', 'LOKASI', 1, 16, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, `INSERT INTO product_categories (id, name, code, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Egg', 'EGG', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, `INSERT INTO products ( id, name, brand, sku, uom_id, product_category_id, product_price, selling_price, tax, expiry_period, created_by, created_at, updated_at, deleted_at, is_visible ) VALUES ( 8, 'Telur Utuh', '', NULL, 1, 1, 0, NULL, NULL, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, 1 )`, `INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES (10, 8, 1, NULL, 50)`, `INSERT INTO flags (name, flagable_id, flagable_type) VALUES ('TELUR', 8, 'products')`, `INSERT INTO fifo_stock_v2_flag_groups (code, is_active) VALUES ('EGG', 1)`, `INSERT INTO fifo_stock_v2_flag_members (flag_name, flag_group_code, is_active) VALUES ('TELUR', 'EGG', 1)`, `INSERT INTO fifo_stock_v2_route_rules (lane, function_code, source_table, flag_group_code, legacy_type_key, allow_pending_default, is_active) VALUES ('USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'EGG', 'STOCK_TRANSFER_OUT', 0, 1)`, `INSERT INTO stock_logs (id, product_warehouse_id, created_by, increase, decrease, stock, loggable_type, loggable_id, notes, created_at) VALUES (1, 10, 1, 50, 0, 50, 'PURCHASE', 1, 'seed', CURRENT_TIMESTAMP)`, } for _, stmt := range statements { if err := db.Exec(stmt).Error; err != nil { t.Fatalf("failed preparing test schema: %v\nstatement: %s", err, stmt) } } return db }