mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
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.
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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: ¬e,
|
||||
})
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user