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) }