mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
812db3f79e
- 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.
305 lines
10 KiB
Go
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: ¬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)
|
|
}
|