From f3ddd7997409d62330a35f4b343bd986d963719c Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Tue, 27 Jan 2026 14:52:32 +0700 Subject: [PATCH] fix(BE): sorting by date approval get all --- .../common/service/common.approval.service.go | 9 +- .../controllers/approval.controller.go | 11 + .../validations/approval.validation.go | 1 + .../closings/services/closing.service.go | 4 +- .../transfer_fifo_integration_test.go | 304 ------------ .../recording_fifo_integration_test.go | 446 ------------------ 6 files changed, 21 insertions(+), 754 deletions(-) delete mode 100644 test/integration/inventory/transfers/transfer_fifo_integration_test.go delete mode 100644 test/integration/production/recordings/recording_fifo_integration_test.go diff --git a/internal/common/service/common.approval.service.go b/internal/common/service/common.approval.service.go index 569a7cc6..c509c22b 100644 --- a/internal/common/service/common.approval.service.go +++ b/internal/common/service/common.approval.service.go @@ -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 { diff --git a/internal/modules/approvals/controllers/approval.controller.go b/internal/modules/approvals/controllers/approval.controller.go index 94a66afd..b7d6b870 100644 --- a/internal/modules/approvals/controllers/approval.controller.go +++ b/internal/modules/approvals/controllers/approval.controller.go @@ -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 diff --git a/internal/modules/approvals/validations/approval.validation.go b/internal/modules/approvals/validations/approval.validation.go index 7338550e..51ec7fa1 100644 --- a/internal/modules/approvals/validations/approval.validation.go +++ b/internal/modules/approvals/validations/approval.validation.go @@ -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"` } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 5494a835..4b87243c 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -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 } diff --git a/test/integration/inventory/transfers/transfer_fifo_integration_test.go b/test/integration/inventory/transfers/transfer_fifo_integration_test.go deleted file mode 100644 index d9f127a1..00000000 --- a/test/integration/inventory/transfers/transfer_fifo_integration_test.go +++ /dev/null @@ -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: ¬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) -} diff --git a/test/integration/production/recordings/recording_fifo_integration_test.go b/test/integration/production/recordings/recording_fifo_integration_test.go deleted file mode 100644 index dd5f7d53..00000000 --- a/test/integration/production/recordings/recording_fifo_integration_test.go +++ /dev/null @@ -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 -}