Merge branch 'fix/sort-approval' into 'development'

fix(BE): sorting by date approval get all

See merge request mbugroup/lti-api!262
This commit is contained in:
Hafizh A. Y.
2026-01-27 07:53:21 +00:00
6 changed files with 21 additions and 754 deletions
@@ -15,7 +15,7 @@ type ApprovalService interface {
WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string
WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool)
CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error)
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string) ([]entity.Approval, int64, error)
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string, sortByDate string) ([]entity.Approval, int64, error)
ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error)
LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error)
LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error)
@@ -70,9 +70,14 @@ func (s *approvalService) List(
approvableID *uint,
page, limit int,
search string,
sortByDate string,
) ([]entity.Approval, int64, error) {
module = strings.TrimSpace(strings.ToUpper(module))
search = strings.TrimSpace(search)
sortByDate = strings.TrimSpace(strings.ToUpper(sortByDate))
if sortByDate != "ASC" && sortByDate != "DESC" {
sortByDate = "DESC"
}
if limit <= 0 {
limit = 10
@@ -90,7 +95,7 @@ func (s *approvalService) List(
func(db *gorm.DB) *gorm.DB {
query := db.
Where("approvable_type = ?", module).
Order("action_at DESC").
Order("action_at " + sortByDate).
Preload("ActionUser")
if approvableID != nil {
@@ -44,6 +44,15 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10)
search := strings.TrimSpace(c.Query("search", ""))
sortByDate := strings.TrimSpace(c.Query("sort_by_date", ""))
if sortByDate == "" {
sortByDate = "DESC"
} else {
sortByDate = strings.ToUpper(sortByDate)
if sortByDate != "ASC" && sortByDate != "DESC" {
return fiber.NewError(fiber.StatusBadRequest, "sort_by_date must be either ASC or DESC")
}
}
query := &validation.Query{
ModuleName: moduleName,
@@ -52,6 +61,7 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
Page: page,
Limit: limit,
Search: search,
SortByDate: sortByDate,
}
records, totalResults, err := u.ApprovalService.List(
@@ -61,6 +71,7 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
query.Page,
query.Limit,
query.Search,
query.SortByDate,
)
if err != nil {
return err
@@ -7,4 +7,5 @@ type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
SortByDate string `query:"sort_by_date" validate:"omitempty,oneof=ASC DESC"`
}
@@ -264,7 +264,7 @@ func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectF
statusProject := "Belum Selesai"
var approvalDate string
if s.ApprovalSvc != nil {
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "")
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "", "")
if err != nil {
s.Log.Errorf("Failed to fetch approvals for project flock kandang %d: %+v", kandang.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval data")
@@ -542,7 +542,7 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID
return "", "Belum Selesai", nil
}
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "")
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "", "")
if err != nil {
return "", "", err
}
@@ -1,304 +0,0 @@
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)
}
@@ -1,446 +0,0 @@
package test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"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"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
servicePkg "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
)
func TestRecordingFIFO_CreatePendingWithoutStock(t *testing.T) {
db, svc, _, _ := setupRecordingFIFOTableTest(t)
ctx := context.Background()
recordingID := uint(1)
productWarehouse := createProductWarehouseRow(t, db, 0)
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks (pending) failed: %v", err)
}
updated := fetchRecordingStock(t, db, stock.Id)
assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should remain zero when no stock is available")
assertFloatEqual(t, 10, updated.PendingQty, "pending_qty should capture the entire request")
assertWarehouseQuantity(t, db, productWarehouse.Id, 0)
assertAllocationCount(t, db, 0)
assertAllocationCount(t, db, 0)
}
func TestRecordingFIFO_EditReallocatesUsage(t *testing.T) {
db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t)
ctx := context.Background()
recordingID := uint(1)
productWarehouse := createProductWarehouseRow(t, db, 0)
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
lot := createStockLot(t, db, productWarehouse.Id)
if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: stockableKey,
StockableID: lot.Id,
ProductWarehouseID: productWarehouse.Id,
Quantity: 12,
}); err != nil {
t.Fatalf("replenish failed: %v", err)
}
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks (initial) failed: %v", err)
}
assertWarehouseQuantity(t, db, productWarehouse.Id, 2)
desired := 4.0
stock.UsageQty = &desired
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks (edit) failed: %v", err)
}
updated := fetchRecordingStock(t, db, stock.Id)
assertFloatEqual(t, 4, updated.UsageQty, "usage_qty should reflect edited request")
assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should remain zero after downsize")
assertWarehouseQuantity(t, db, productWarehouse.Id, 8)
alloc := fetchSingleAllocation(t, db, stock.Id)
if alloc.Status != entity.StockAllocationStatusActive {
t.Fatalf("expected ACTIVE allocation, got %s", alloc.Status)
}
if mathAbs(alloc.Qty-4) > 1e-6 {
t.Fatalf("expected allocation qty 4, got %.3f", alloc.Qty)
}
}
func TestRecordingFIFO_DeleteReleasesStock(t *testing.T) {
db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t)
ctx := context.Background()
recordingID := uint(1)
productWarehouse := createProductWarehouseRow(t, db, 0)
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
lot := createStockLot(t, db, productWarehouse.Id)
if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: stockableKey,
StockableID: lot.Id,
ProductWarehouseID: productWarehouse.Id,
Quantity: 10,
}); err != nil {
t.Fatalf("replenish failed: %v", err)
}
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks failed: %v", err)
}
if err := svc.ReleaseRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("releaseRecordingStocks failed: %v", err)
}
updated := fetchRecordingStock(t, db, stock.Id)
assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should be cleared after delete")
assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should be cleared after delete")
assertWarehouseQuantity(t, db, productWarehouse.Id, 10)
alloc := fetchSingleAllocation(t, db, stock.Id)
if alloc.Status != entity.StockAllocationStatusReleased {
t.Fatalf("expected allocation to be released, got %s", alloc.Status)
}
}
// --- helpers ----------------------------------------------------------------
type recordingStockTable struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
UsageQty *float64 `gorm:"column:usage_qty"`
PendingQty *float64 `gorm:"column:pending_qty"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (recordingStockTable) TableName() string { return "recording_stocks" }
type productWarehouseTable struct {
Id uint `gorm:"primaryKey"`
ProductId uint `gorm:"column:product_id"`
WarehouseId uint `gorm:"column:warehouse_id"`
Quantity float64 `gorm:"column:quantity"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (productWarehouseTable) TableName() string { return "product_warehouses" }
type stockAllocationTable struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"not null"`
StockableType string `gorm:"size:100"`
StockableId uint
UsableType string `gorm:"size:100"`
UsableId uint
Qty float64 `gorm:"column:qty"`
Status string `gorm:"size:20"`
Note *string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
ReleasedAt *time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (stockAllocationTable) TableName() string { return "stock_allocations" }
type testStockSource struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
TotalQty float64 `gorm:"column:total_qty"`
TotalUsedQty float64 `gorm:"column:total_used_qty"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time
}
func (testStockSource) TableName() string { return "test_fifo_stockables" }
func setupRecordingFIFOTableTest(t *testing.T) (*gorm.DB, servicePkg.RecordingFIFOIntegrationService, commonSvc.FifoService, fifo.StockableKey) {
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 sqlite: %v", err)
}
if err := db.AutoMigrate(
&recordingStockTable{},
&productWarehouseTable{},
&stockAllocationTable{},
&testStockSource{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
if err := db.AutoMigrate(
&entity.ProductWarehouse{},
&entity.StockAllocation{},
&entity.RecordingStock{},
); err != nil {
t.Fatalf("auto migrate entities: %v", err)
}
stockAllocRepo := newFifoTestStockAllocationRepo(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
registerRecordingUsable(t, fifoSvc)
key := fifo.StockableKey(fmt.Sprintf("TEST_STOCKABLE_%s_%d", sanitizeKey(t.Name()), time.Now().UnixNano()))
if err := fifoSvc.RegisterStockable(fifo.StockableConfig{
Key: key,
Table: "test_fifo_stockables",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used_qty",
CreatedAt: "created_at",
},
}); err != nil {
t.Fatalf("register stockable: %v", err)
}
svc := servicePkg.NewRecordingFIFOIntegrationService(
recordingRepo.NewRecordingRepository(db),
productWarehouseRepo,
fifoSvc,
)
return db, svc, fifoSvc, key
}
func registerRecordingUsable(t *testing.T, fifoSvc commonSvc.FifoService) {
t.Helper()
err := fifoSvc.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingStock,
Table: "recording_stocks",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
})
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
t.Fatalf("register usable: %v", err)
}
if _, ok := fifo.Usable(fifo.UsableKeyRecordingStock); !ok {
t.Fatal("recording stock usable key not registered")
}
}
func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse {
t.Helper()
pw := entity.ProductWarehouse{
ProductId: 1,
WarehouseId: 1,
Quantity: qty,
// CreatedBy: 1,
}
if err := db.Create(&pw).Error; err != nil {
t.Fatalf("create product warehouse: %v", err)
}
return pw
}
func createRecordingStockRow(t *testing.T, db *gorm.DB, recordingID, productWarehouseID uint, desired float64) entity.RecordingStock {
t.Helper()
stock := entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: productWarehouseID,
UsageQty: floatPtr(0),
PendingQty: floatPtr(0),
}
if err := db.Create(&stock).Error; err != nil {
t.Fatalf("create recording stock: %v", err)
}
stock.UsageQty = floatPtr(desired)
return stock
}
func createStockLot(t *testing.T, db *gorm.DB, productWarehouseID uint) testStockSource {
t.Helper()
lot := testStockSource{
ProductWarehouseId: productWarehouseID,
CreatedAt: time.Now(),
}
if err := db.Create(&lot).Error; err != nil {
t.Fatalf("create stock lot: %v", err)
}
return lot
}
func fetchRecordingStock(t *testing.T, db *gorm.DB, id uint) entity.RecordingStock {
t.Helper()
var stock entity.RecordingStock
if err := db.First(&stock, id).Error; err != nil {
t.Fatalf("fetch recording stock: %v", err)
}
return stock
}
func fetchSingleAllocation(t *testing.T, db *gorm.DB, usableID uint) entity.StockAllocation {
t.Helper()
var alloc entity.StockAllocation
if err := db.Where("usable_id = ?", usableID).Order("created_at ASC").First(&alloc).Error; err != nil {
t.Fatalf("fetch allocation: %v", err)
}
return alloc
}
func assertAllocationCount(t *testing.T, db *gorm.DB, expected int64) {
t.Helper()
var count int64
if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil {
t.Fatalf("count allocations: %v", err)
}
if count != expected {
t.Fatalf("expected %d allocations, got %d", expected, count)
}
}
func assertWarehouseQuantity(t *testing.T, db *gorm.DB, id uint, expected float64) {
t.Helper()
var pw entity.ProductWarehouse
if err := db.First(&pw, id).Error; err != nil {
t.Fatalf("fetch product warehouse: %v", err)
}
if mathAbs(pw.Quantity-expected) > 1e-6 {
t.Fatalf("expected warehouse quantity %.3f, got %.3f", expected, pw.Quantity)
}
}
func assertFloatEqual(t *testing.T, expected float64, value *float64, msg string) {
t.Helper()
if value == nil {
t.Fatalf("expected %s %.3f, got nil", msg, expected)
}
if mathAbs(*value-expected) > 1e-6 {
t.Fatalf("%s: expected %.3f, got %.3f", msg, expected, *value)
}
}
func floatPtr(v float64) *float64 {
p := new(float64)
*p = v
return p
}
func mathAbs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func sanitizeKey(name string) string {
if name == "" {
return "CASE"
}
clean := strings.Map(func(r rune) rune {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
if r >= 'a' && r <= 'z' {
return r - 32
}
return '_'
}, name)
return clean
}
type fifoTestStockAllocationRepo struct {
commonRepo.StockAllocationRepository
db *gorm.DB
}
func newFifoTestStockAllocationRepo(db *gorm.DB) commonRepo.StockAllocationRepository {
return &fifoTestStockAllocationRepo{
StockAllocationRepository: commonRepo.NewStockAllocationRepository(db),
db: db,
}
}
func (r *fifoTestStockAllocationRepo) PatchOne(
ctx context.Context,
id uint,
updates map[string]any,
modifier func(*gorm.DB) *gorm.DB,
) error {
base := r.db
setClauses := make([]string, 0, len(updates))
args := make([]any, 0, len(updates)+1)
for column, value := range updates {
colName := column
if strings.EqualFold(column, "quantity") {
colName = "qty"
}
setClauses = append(setClauses, fmt.Sprintf("%s = ?", colName))
args = append(args, value)
}
args = append(args, id)
sql := fmt.Sprintf("UPDATE stock_allocations SET %s WHERE id = ?", strings.Join(setClauses, ", "))
result := base.Exec(sql, args...)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *fifoTestStockAllocationRepo) ReleaseByUsable(
ctx context.Context,
usableType string,
usableID uint,
note *string,
modifier func(*gorm.DB) *gorm.DB,
) error {
base := r.db
setClause := "status = ?, released_at = ?"
args := []any{entity.StockAllocationStatusReleased, time.Now()}
if note != nil {
setClause += ", note = ?"
args = append(args, *note)
}
args = append(args, usableType, usableID, entity.StockAllocationStatusActive)
sql := fmt.Sprintf(
"UPDATE stock_allocations SET %s WHERE usable_type = ? AND usable_id = ? AND status = ?",
setClause,
)
result := base.Exec(sql, args...)
return result.Error
}