Files
lti-api/internal/modules/inventory/transfers/services/system_transfer_test.go
T

482 lines
18 KiB
Go

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
}