Files
lti-api/test/integration/inventory/transfers/transfer_fifo_integration_test.go
aguhh18 812db3f79e feat(BE): integrate FIFO service for stock adjustments and transfers
- Added FIFO service integration in the adjustments module to manage stockable and usable items for adjustments.
- Created a new repository for adjustment stocks to handle database operations.
- Enhanced the adjustment service to track stock adjustments using FIFO logic for both increase and decrease operations.
- Updated product warehouse DTOs and repositories to include project flock information.
- Implemented FIFO logic in the transfer module to manage stock transfers between warehouses.
- Added integration tests for FIFO operations in stock transfers, ensuring correct stock consumption and replenishment.
2025-12-28 19:15:41 +07:00

305 lines
10 KiB
Go

package test
import (
"context"
"math"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
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"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
)
// Test Transfer FIFO with Purchase as initial stockable
func TestTransferFIFO_PurchaseToTransfer(t *testing.T) {
db, fifoSvc := setupTransferFIFOTest(t)
ctx := context.Background()
// Setup warehouses
sourcePW := createProductWarehouseRow(t, db, 100) // 100 qty from purchase
destPW := createProductWarehouseRow(t, db, 0) // 0 qty initially
// Step 1: Simulate Purchase - Replenish stock to source warehouse
purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS")
if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: purchaseStockableKey,
StockableID: 1, // PurchaseItem ID
ProductWarehouseID: sourcePW.Id,
Quantity: 100,
}); err != nil {
t.Fatalf("Failed to replenish from purchase: %v", err)
}
// Verify source warehouse has stock
assertWarehouseQuantity(t, db, sourcePW.Id, 100)
assertAllocationCount(t, db, 1) // 1 allocation from purchase
// Step 2: Create Transfer - will consume from source (usable) and replenish to dest (stockable)
// Register Transfer as Usable (source warehouse - STOCK_TRANSFER_OUT)
transferUsableKey := fifo.UsableKey("STOCK_TRANSFER_OUT")
if err := fifoSvc.RegisterUsable(fifo.UsableConfig{
Key: transferUsableKey,
Table: "stock_transfer_details",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
t.Fatalf("Failed to register STOCK_TRANSFER_OUT as Usable: %v", err)
}
// Register Transfer as Stockable (destination warehouse - STOCK_TRANSFER_IN)
transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_IN")
if err := fifoSvc.RegisterStockable(fifo.StockableConfig{
Key: transferStockableKey,
Table: "stock_transfer_details",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "dest_product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
t.Fatalf("Failed to register STOCK_TRANSFER_IN as Stockable: %v", err)
}
// Create transfer detail record
transferDetail := entity.StockTransferDetail{
Id: 1,
StockTransferId: 1,
ProductId: 1,
SourceProductWarehouseID: uint64Ptr(uint64(sourcePW.Id)),
DestProductWarehouseID: uint64Ptr(uint64(destPW.Id)),
UsageQty: 0,
PendingQty: 0,
TotalQty: 0,
TotalUsed: 0,
}
transferDetailID := uint(transferDetail.Id)
if err := db.Create(&transferDetail).Error; err != nil {
t.Fatalf("Failed to create transfer detail: %v", err)
}
transferQty := 50.0
// Consume from source warehouse (STOCK_TRANSFER_OUT)
consumeResult, err := fifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: "STOCK_TRANSFER_OUT",
UsableID: transferDetailID,
ProductWarehouseID: sourcePW.Id,
Quantity: transferQty,
AllowPending: false, // Don't allow pending
})
if err != nil {
t.Fatalf("Failed to consume from source warehouse: %v", err)
}
// Verify consumption
if mathAbs(consumeResult.UsageQuantity-transferQty) > 1e-6 {
t.Fatalf("Expected usage quantity %.2f, got %.2f", transferQty, consumeResult.UsageQuantity)
}
if mathAbs(consumeResult.PendingQuantity) > 1e-6 {
t.Fatalf("Expected pending quantity 0, got %.2f", consumeResult.PendingQuantity)
}
// Update transfer detail usable fields
if err := db.Model(&entity.StockTransferDetail{}).
Where("id = ?", transferDetail.Id).
Updates(map[string]interface{}{
"usage_qty": consumeResult.UsageQuantity,
"pending_qty": consumeResult.PendingQuantity,
}).Error; err != nil {
t.Fatalf("Failed to update transfer detail usable fields: %v", err)
}
// Verify source warehouse decreased
assertWarehouseQuantity(t, db, sourcePW.Id, 50) // 100 - 50 = 50
// Verify allocation updated - should have 50 allocated to transfer
allocations := fetchAllocationsByUsable(t, db, "STOCK_TRANSFER_OUT", transferDetailID)
if len(allocations) != 1 {
t.Fatalf("Expected 1 allocation, got %d", len(allocations))
}
if mathAbs(allocations[0].Qty-transferQty) > 1e-6 {
t.Fatalf("Expected allocation qty %.2f, got %.2f", transferQty, allocations[0].Qty)
}
// Replenish to destination warehouse (STOCK_TRANSFER_IN)
note := "Transfer #1"
replenishResult, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: "STOCK_TRANSFER_IN",
StockableID: transferDetailID,
ProductWarehouseID: destPW.Id,
Quantity: transferQty,
Note: &note,
})
if err != nil {
t.Fatalf("Failed to replenish to destination warehouse: %v", err)
}
// Verify replenishment
if mathAbs(replenishResult.AddedQuantity-transferQty) > 1e-6 {
t.Fatalf("Expected added quantity %.2f, got %.2f", transferQty, replenishResult.AddedQuantity)
}
// Update transfer detail stockable fields
if err := db.Model(&entity.StockTransferDetail{}).
Where("id = ?", transferDetail.Id).
Updates(map[string]interface{}{
"total_qty": replenishResult.AddedQuantity,
}).Error; err != nil {
t.Fatalf("Failed to update transfer detail stockable fields: %v", err)
}
// Verify destination warehouse increased
assertWarehouseQuantity(t, db, destPW.Id, transferQty)
// Verify new stockable allocation created
stockableAllocations := fetchAllocationsByStockable(t, db, "STOCK_TRANSFER_IN", transferDetailID)
if len(stockableAllocations) != 1 {
t.Fatalf("Expected 1 stockable allocation, got %d", len(stockableAllocations))
}
if mathAbs(stockableAllocations[0].Qty-transferQty) > 1e-6 {
t.Fatalf("Expected stockable allocation qty %.2f, got %.2f", transferQty, stockableAllocations[0].Qty)
}
t.Logf("✅ Transfer FIFO test passed:")
t.Logf(" - Source warehouse: 100 → 50 (consumed %d)", int(transferQty))
t.Logf(" - Destination warehouse: 0 → %d (replenished)", int(transferQty))
t.Logf(" - Usable allocation: %.2f allocated to transfer", allocations[0].Qty)
t.Logf(" - Stockable allocation: %.2f available at destination", stockableAllocations[0].Qty)
}
// Setup function for transfer FIFO test
func setupTransferFIFOTest(t *testing.T) (*gorm.DB, commonSvc.FifoService) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(
&entity.ProductWarehouse{},
&entity.StockAllocation{},
&entity.StockTransferDetail{},
); err != nil {
t.Fatalf("auto migrate entities: %v", err)
}
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
// Register Purchase as Stockable
purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS")
if err := fifoSvc.RegisterStockable(fifo.StockableConfig{
Key: purchaseStockableKey,
Table: "purchase_items",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
t.Fatalf("register purchase stockable: %v", err)
}
return db, fifoSvc
}
// Helper functions
func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse {
t.Helper()
pw := entity.ProductWarehouse{
ProductId: 1,
WarehouseId: 1,
Quantity: qty,
}
if err := db.Create(&pw).Error; err != nil {
t.Fatalf("create product warehouse: %v", err)
}
return pw
}
func assertWarehouseQuantity(t *testing.T, db *gorm.DB, pwID uint, expected float64) {
t.Helper()
var pw entity.ProductWarehouse
if err := db.First(&pw, pwID).Error; err != nil {
t.Fatalf("fetch product warehouse %d: %v", pwID, err)
}
if mathAbs(pw.Quantity-expected) > 1e-6 {
t.Fatalf("expected warehouse quantity %.2f, got %.2f", expected, pw.Quantity)
}
}
func assertAllocationCount(t *testing.T, db *gorm.DB, expected int) {
t.Helper()
var count int64
if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil {
t.Fatalf("count allocations: %v", err)
}
if int(count) != expected {
t.Fatalf("expected %d allocations, got %d", expected, count)
}
}
func fetchAllocationsByUsable(t *testing.T, db *gorm.DB, usableType string, usableID uint) []entity.StockAllocation {
t.Helper()
var allocations []entity.StockAllocation
if err := db.Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Find(&allocations).Error; err != nil {
t.Fatalf("fetch allocations by usable: %v", err)
}
return allocations
}
func fetchAllocationsByStockable(t *testing.T, db *gorm.DB, stockableType string, stockableID uint) []entity.StockAllocation {
t.Helper()
var allocations []entity.StockAllocation
if err := db.Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
Find(&allocations).Error; err != nil {
t.Fatalf("fetch allocations by stockable: %v", err)
}
return allocations
}
func floatPtr(f float64) *float64 {
return &f
}
func uint64Ptr(u uint64) *uint64 {
return &u
}
func mathAbs(f float64) float64 {
return math.Abs(f)
}
func sanitizeKey(name string) string {
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return '_'
}, name)
}