From 812db3f79ec82eef442670b424a10bf158c5336d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 28 Dec 2025 19:15:41 +0700 Subject: [PATCH] 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. --- ...4527_recreate_project_chikins_table.up.sql | 2 +- ..._fields_to_stock_transfer_details.down.sql | 42 +++ ...fo_fields_to_stock_transfer_details.up.sql | 83 +++++ ...12_create_adjustment_stocks_table.down.sql | 16 + ...2012_create_adjustment_stocks_table.up.sql | 40 +++ internal/entities/adjustment_stock.go | 29 ++ internal/entities/stock_transfer_detail.go | 32 +- .../modules/inventory/adjustments/module.go | 53 ++- .../adjustment_stock.repository.go | 50 +++ .../services/adjustment.service.go | 100 +++++- .../dto/product_warehouse.dto.go | 50 ++- .../product_warehouse.repository.go | 24 +- .../services/product_warehouse.service.go | 3 +- .../inventory/transfers/dto/transfer.dto.go | 4 +- .../modules/inventory/transfers/module.go | 42 ++- .../transfers/services/transfer.service.go | 180 +++++++---- .../transfer_fifo_integration_test.go | 304 ++++++++++++++++++ 17 files changed, 950 insertions(+), 104 deletions(-) create mode 100644 internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql create mode 100644 internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql create mode 100644 internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql create mode 100644 internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql create mode 100644 internal/entities/adjustment_stock.go create mode 100644 internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go create mode 100644 test/integration/inventory/transfers/transfer_fifo_integration_test.go diff --git a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql index e029646b..4ece8942 100644 --- a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql +++ b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql @@ -29,7 +29,7 @@ ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id -- Relasi ke product_warehouses ALTER TABLE project_chickins -ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE; +ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE; -- Relasi ke users ALTER TABLE project_chickins diff --git a/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql new file mode 100644 index 00000000..9b5b8164 --- /dev/null +++ b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql @@ -0,0 +1,42 @@ +-- =============================================================== +-- ROLLBACK: Remove FIFO fields from STOCK_TRANSFER_DETAILS +-- =============================================================== + +-- Drop indexes +DROP INDEX IF EXISTS idx_stock_transfer_details_dest_pw; +DROP INDEX IF EXISTS idx_stock_transfer_details_source_pw; + +-- Drop foreign keys +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_source_pw' + ) THEN + EXECUTE 'ALTER TABLE stock_transfer_details + DROP CONSTRAINT fk_stock_transfer_details_source_pw'; + END IF; + + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_dest_pw' + ) THEN + EXECUTE 'ALTER TABLE stock_transfer_details + DROP CONSTRAINT fk_stock_transfer_details_dest_pw'; + END IF; +END $$; + +-- Drop FIFO columns +ALTER TABLE stock_transfer_details +DROP COLUMN IF EXISTS total_used, +DROP COLUMN IF EXISTS total_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS dest_product_warehouse_id, +DROP COLUMN IF EXISTS source_product_warehouse_id; + +-- Restore original columns (in case rollback) +ALTER TABLE stock_transfer_details +ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3), +ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3); diff --git a/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql new file mode 100644 index 00000000..7f6ad5cb --- /dev/null +++ b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql @@ -0,0 +1,83 @@ +-- =============================================================== +-- ADD FIFO FIELDS TO STOCK_TRANSFER_DETAILS +-- Enable transfer module to work with FIFO stock system +-- +-- Notes: +-- - Field 'quantity' will be removed (replaced by usage_qty + pending_qty) +-- - Fields 'before_quantity' & 'after_quantity' will be removed (unused legacy) +-- - New FIFO fields track actual allocation instead of requested quantity +-- =============================================================== + +-- Add FIFO tracking fields +ALTER TABLE stock_transfer_details +ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT, +ADD COLUMN IF NOT EXISTS dest_product_warehouse_id BIGINT, +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS total_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS total_used NUMERIC(15, 3) DEFAULT 0; + +-- Remove obsolete columns (quantity replaced by FIFO fields, legacy fields never used) +ALTER TABLE stock_transfer_details +DROP COLUMN IF EXISTS quantity, +DROP COLUMN IF EXISTS before_quantity, +DROP COLUMN IF EXISTS after_quantity; + +-- Add foreign keys for product warehouse references +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + -- Source warehouse foreign key + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_source_pw' + ) THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_source_pw + FOREIGN KEY (source_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + + -- Destination warehouse foreign key + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_dest_pw' + ) THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_dest_pw + FOREIGN KEY (dest_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; +END $$; + +-- Add indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_source_pw +ON stock_transfer_details (source_product_warehouse_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_dest_pw +ON stock_transfer_details (dest_product_warehouse_id); + +-- Add comments for documentation +COMMENT ON COLUMN stock_transfer_details.source_product_warehouse_id IS +'Source product warehouse ID - referensi warehouse asal (FIFO usable)'; + +COMMENT ON COLUMN stock_transfer_details.dest_product_warehouse_id IS +'Destination product warehouse ID - referensi warehouse tujuan (FIFO stockable)'; + +COMMENT ON COLUMN stock_transfer_details.usage_qty IS +'Actual quantity successfully taken from source warehouse (FIFO usable tracking) - replaces quantity field'; + +COMMENT ON COLUMN stock_transfer_details.pending_qty IS +'Quantity waiting for stock availability (FIFO usable tracking)'; + +COMMENT ON COLUMN stock_transfer_details.total_qty IS +'Total lot quantity available at destination warehouse (FIFO stockable tracking)'; + +COMMENT ON COLUMN stock_transfer_details.total_used IS +'Quantity already consumed from this lot at destination warehouse (FIFO stockable tracking)'; + diff --git a/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql new file mode 100644 index 00000000..9941a992 --- /dev/null +++ b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql @@ -0,0 +1,16 @@ +-- Rollback: Drop adjustment_stocks table + +BEGIN; + +DROP INDEX IF EXISTS idx_adjustment_stocks_product_warehouse; +DROP INDEX IF EXISTS idx_adjustment_stocks_stock_log; + +ALTER TABLE adjustment_stocks +DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_product_warehouse; + +ALTER TABLE adjustment_stocks +DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_stock_log; + +DROP TABLE IF EXISTS adjustment_stocks; + +COMMIT; diff --git a/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql new file mode 100644 index 00000000..1c79439b --- /dev/null +++ b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql @@ -0,0 +1,40 @@ +-- Migration: Create adjustment_stocks table for FIFO tracking +-- This table tracks FIFO allocation for stock adjustments (both increase and decrease) + +BEGIN; + +CREATE TABLE IF NOT EXISTS adjustment_stocks ( + id BIGSERIAL PRIMARY KEY, + stock_log_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + + -- FIFO fields for Adjustment INCREASE (Stockable) + -- Tracks stock added to warehouse via adjustment + total_qty NUMERIC(15, 3) DEFAULT 0, + total_used NUMERIC(15, 3) DEFAULT 0, + + -- FIFO fields for Adjustment DECREASE (Usable) + -- Tracks stock consumed from warehouse via adjustment + usage_qty NUMERIC(15, 3) DEFAULT 0, + pending_qty NUMERIC(15, 3) DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Foreign keys +ALTER TABLE adjustment_stocks +ADD CONSTRAINT fk_adjustment_stocks_stock_log +FOREIGN KEY (stock_log_id) REFERENCES stock_logs(id) +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE adjustment_stocks +ADD CONSTRAINT fk_adjustment_stocks_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id) +ON DELETE CASCADE ON UPDATE CASCADE; + +-- Indexes +CREATE INDEX idx_adjustment_stocks_stock_log ON adjustment_stocks(stock_log_id); +CREATE INDEX idx_adjustment_stocks_product_warehouse ON adjustment_stocks(product_warehouse_id); + +COMMIT; diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go new file mode 100644 index 00000000..bbc93167 --- /dev/null +++ b/internal/entities/adjustment_stock.go @@ -0,0 +1,29 @@ +package entities + +import "time" + +// AdjustmentStock tracks FIFO allocation for stock adjustments +// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse +// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse +type AdjustmentStock struct { + Id uint `gorm:"primaryKey"` + StockLogId uint `gorm:"column:stock_log_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + + // === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) === + // Tracks stock added to warehouse via adjustment INCREASE + TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available + TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot + + // === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) === + // Tracks stock consumed from warehouse via adjustment DECREASE + UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed + PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock) + + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` + + // Relations + StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go index 253a3bf8..9ab27824 100644 --- a/internal/entities/stock_transfer_detail.go +++ b/internal/entities/stock_transfer_detail.go @@ -7,12 +7,28 @@ type StockTransferDetail struct { Id uint64 `gorm:"primaryKey;autoIncrement"` StockTransferId uint64 ProductId uint64 - Quantity float64 - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` - // Relations - StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` - Product *Product `gorm:"foreignKey:ProductId"` - DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` + + // === FIFO FIELDS - SOURCE WAREHOUSE (Usable) === + // Tracking stock yang DIAMBIL dari source warehouse + SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"` + UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil + PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock) + + // === FIFO FIELDS - DESTINATION WAREHOUSE (Stockable) === + // Tracking stock yang DITAMBAHKAN ke destination warehouse + DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"` + TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia + TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini + + // === METADATA === + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + + // === RELATIONS === + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Product *Product `gorm:"foreignKey:ProductId"` + SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"` + DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"` + DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` } diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index 610dc11e..08e556ea 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -5,6 +5,9 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" @@ -13,19 +16,67 @@ import ( rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type AdjustmentModule struct{} func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + // Repositories stockLogsRepo := rStockLogs.NewStockLogRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) + adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate, projectFlockKandangRepo) + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("ADJUSTMENT_IN"), + Table: "adjustment_stocks", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic("Failed to register ADJUSTMENT_IN as Stockable: " + err.Error()) + } + + err = fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("ADJUSTMENT_OUT"), + Table: "adjustment_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic("Failed to register ADJUSTMENT_OUT as Usable: " + err.Error()) + } + + adjustmentService := sAdjustment.NewAdjustmentService( + productRepo, + stockLogsRepo, + warehouseRepo, + productWarehouseRepo, + adjustmentStockRepo, + fifoService, + validate, + projectFlockKandangRepo, + ) userService := sUser.NewUserService(userRepo, validate) AdjustmentRoutes(router, userService, adjustmentService) diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go new file mode 100644 index 00000000..8d62b05c --- /dev/null +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -0,0 +1,50 @@ +package repositories + +import ( + "context" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type AdjustmentStockRepository interface { + CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error + GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) + WithTx(tx *gorm.DB) AdjustmentStockRepository + DB() *gorm.DB +} + +type adjustmentStockRepositoryImpl struct { + db *gorm.DB +} + +func NewAdjustmentStockRepository(db *gorm.DB) AdjustmentStockRepository { + return &adjustmentStockRepositoryImpl{db: db} +} + +func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error { + q := r.db.WithContext(ctx) + if modifier != nil { + q = modifier(q) + } + return q.Create(data).Error +} + +func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { + var record entity.AdjustmentStock + err := r.db.WithContext(ctx). + Where("stock_log_id = ?", stockLogID). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepository { + return &adjustmentStockRepositoryImpl{db: tx} +} + +func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB { + return r.db +} diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 5a634382..d7b1641b 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -12,6 +12,7 @@ import ( common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" @@ -29,24 +30,37 @@ type AdjustmentService interface { } type adjustmentService struct { - Log *logrus.Logger - Validate *validator.Validate - StockLogsRepository stockLogsRepo.StockLogRepository - WarehouseRepo warehouseRepo.WarehouseRepository - ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository - ProductRepo productRepo.ProductRepository - ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + Log *logrus.Logger + Validate *validator.Validate + StockLogsRepository stockLogsRepo.StockLogRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository + ProductRepo productRepo.ProductRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository + FifoSvc common.FifoService } -func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) AdjustmentService { +func NewAdjustmentService( + productRepo productRepo.ProductRepository, + stockLogsRepo stockLogsRepo.StockLogRepository, + warehouseRepo warehouseRepo.WarehouseRepository, + productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, + adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository, + fifoSvc common.FifoService, + validate *validator.Validate, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, +) AdjustmentService { return &adjustmentService{ - Log: utils.Log, - Validate: validate, - StockLogsRepository: stockLogsRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - ProductRepo: productRepo, - ProjectFlockKandangRepo: projectFlockKandangRepo, + Log: utils.Log, + Validate: validate, + StockLogsRepository: stockLogsRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + AdjustmentStockRepository: adjustmentStockRepo, + FifoSvc: fifoSvc, } } @@ -152,15 +166,16 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } + // Create StockLog for history tracking afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ - LoggableType: string(utils.StockLogTypeAdjustment), LoggableId: 0, Notes: req.Note, ProductWarehouseId: productWarehouse.Id, - CreatedBy: actorID, // TODO: should Get from auth middleware + CreatedBy: actorID, } + if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity newLog.Increase = afterQuantity @@ -177,6 +192,57 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return err } + // Create AdjustmentStock record for FIFO tracking + adjustmentStock := &entity.AdjustmentStock{ + StockLogId: newLog.Id, + ProductWarehouseId: productWarehouse.Id, + } + + if transactionType == string(utils.StockLogTransactionTypeIncrease) { + // Adjustment INCREASE → Replenish stock (Stockable) + note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) + replenishResult, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ + StockableKey: "ADJUSTMENT_IN", + StockableID: newLog.Id, + ProductWarehouseID: uint(productWarehouse.Id), + Quantity: req.Quantity, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err)) + } + + // Update stockable tracking fields + adjustmentStock.TotalQty = replenishResult.AddedQuantity + adjustmentStock.TotalUsed = 0 + + } else { + // Adjustment DECREASE → Consume stock (Usable) + consumeResult, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ + UsableKey: "ADJUSTMENT_OUT", + UsableID: newLog.Id, + ProductWarehouseID: uint(productWarehouse.Id), + Quantity: req.Quantity, + AllowPending: false, // Don't allow pending for adjustment + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err)) + } + + // Update usable tracking fields + adjustmentStock.UsageQty = consumeResult.UsageQuantity + adjustmentStock.PendingQty = consumeResult.PendingQuantity + } + + // Save AdjustmentStock record + if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { + s.Log.Errorf("Failed to create adjustment stock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") + } + + // Update ProductWarehouse quantity (for backward compatibility/reporting) productWarehouse.Quantity = afterQuantity if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 81fbec1f..57a13021 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -24,11 +24,12 @@ type ProductWarehousNestedDTO struct { type ProductWarehouseListDTO struct { ProductWarehouseRelationDTO - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` - CreatedUser *UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` + CreatedUser *UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type UserRelationDTO struct { @@ -71,6 +72,19 @@ type AreaRelationDTO struct { Name string `json:"name"` } +type ProjectFlockKandangRelationDTO struct { + Id uint `json:"id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Period int `json:"period"` + ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"` +} + +type ProjectFlockRelationDTO struct { + Id uint `json:"id"` + FlockName string `json:"flock_name"` +} + // === Mapper Functions === func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { @@ -105,6 +119,12 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT // Map Product relation jika ada if e.Product.Id != 0 { product := productDTO.ToProductRelationDTO(e.Product) + + // Tambahkan flock name ke product name jika ada project flock + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { + product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + } + dto.Product = &product } @@ -139,6 +159,26 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT dto.Warehouse = &warehouse } + // Map ProjectFlockKandang relation jika ada + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.Id != 0 { + pfkDTO := &ProjectFlockKandangRelationDTO{ + Id: e.ProjectFlockKandang.Id, + ProjectFlockId: e.ProjectFlockKandang.ProjectFlockId, + KandangId: e.ProjectFlockKandang.KandangId, + Period: e.ProjectFlockKandang.Period, + } + + // Map ProjectFlock jika ada + if e.ProjectFlockKandang.ProjectFlock.Id != 0 { + pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ + Id: e.ProjectFlockKandang.ProjectFlock.Id, + FlockName: e.ProjectFlockKandang.ProjectFlock.FlockName, + } + } + + dto.ProjectFlockKandang = pfkDTO + } + // Map CreatedUser relation jika ada // if e.CreatedUser.Id != 0 { // user := UserRelationDTO{ diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index 4f213f2c..e759138e 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -81,9 +81,29 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) { var productWarehouse entity.ProductWarehouse - if err := r.DB().WithContext(ctx).Where("product_id = ? AND warehouse_id = ?", productId, warehouseId).First(&productWarehouse).Error; err != nil { + + err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId). + Order("id DESC"). + Preload("ProjectFlockKandang"). + First(&productWarehouse).Error + + if err == nil { + + if productWarehouse.ProjectFlockKandang.ClosedAt == nil { + return &productWarehouse, nil + } + + } + + err = r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId). + First(&productWarehouse).Error + + if err != nil { return nil, err } + return &productWarehouse, nil } @@ -244,6 +264,8 @@ func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id u Preload("Warehouse"). Preload("Warehouse.Area"). Preload("Warehouse.Location"). + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock"). First(&productWarehouse, id).Error if err != nil { return nil, err diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index f690b2a2..152bfa24 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -44,7 +44,8 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Warehouse.Location"). Preload("Warehouse.Area"). Preload("Warehouse.Kandang"). - Preload("ProjectFlockKandang") + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index f1286595..8f075715 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -159,7 +159,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { Id: d.Product.Id, Name: d.Product.Name, }, - Quantity: d.Quantity, + Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated }) } @@ -229,7 +229,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { Id: d.Product.Id, Name: d.Product.Name, }, - Quantity: d.Quantity, + Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated }) } diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 9389f9f4..60d1764a 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -18,6 +18,8 @@ import ( rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type TransferModule struct{} @@ -34,13 +36,51 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) documentRepo := commonRepo.NewDocumentRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) if err != nil { panic(err) } - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc) + // Initialize FIFO Service + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + // Register Transfer as Stockable (adds stock to destination warehouse) + err = fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("STOCK_TRANSFER_IN"), + Table: "stock_transfer_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "dest_product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic(err) + } + + // Register Transfer as Usable (consumes stock from source warehouse) + err = fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("STOCK_TRANSFER_OUT"), + Table: "stock_transfer_details", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic(err) + } + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index a8a8996e..8ae019a4 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -44,9 +44,10 @@ type transferService struct { WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository DocumentSvc commonSvc.DocumentService + FifoSvc commonSvc.FifoService } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -60,6 +61,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, DocumentSvc: documentSvc, + FifoSvc: fifoSvc, } } @@ -126,6 +128,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { + // === VALIDASI SOURCE WAREHOUSE === pwIDs := make([]uint, 0, len(req.Products)) for _, product := range req.Products { @@ -152,6 +155,21 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, err } + destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID)) + if err != nil { + return nil, err + } + + if s.ProjectFlockKandangRepo != nil { + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + if projectFlockKandang.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") + } + } + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -206,14 +224,62 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - var details []*entity.StockTransferDetail + // Prepare details and fetch product warehouses + details := make([]*entity.StockTransferDetail, 0, len(req.Products)) + detailMap := make(map[uint64]*entity.StockTransferDetail) + for _, product := range req.Products { - details = append(details, &entity.StockTransferDetail{ + // Get source product warehouse + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), + ) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") + } + + // Get or create destination product warehouse + destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), + ) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse destination") + } + if errors.Is(err, gorm.ErrRecordNotFound) { + ctx := c.Context() + projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) + if err != nil { + return err + } + destPW = &entity.ProductWarehouse{ + ProductId: uint(product.ProductID), + WarehouseId: uint(req.DestinationWarehouseID), + Quantity: 0, + ProjectFlockKandangId: &projectFlockKandangID, + } + if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") + } + } + + detail := &entity.StockTransferDetail{ StockTransferId: entityTransfer.Id, ProductId: uint64(product.ProductID), - Quantity: product.ProductQty, - }) + + SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(), + UsageQty: 0, + PendingQty: 0, + + DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(), + TotalQty: 0, + TotalUsed: 0, + } + details = append(details, detail) + detailMap[uint64(product.ProductID)] = detail } + if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { return err } @@ -233,23 +299,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - detailMap := make(map[uint64]uint64) - for _, d := range details { - detailMap[d.ProductId] = d.Id - } - var deliveryItems []*entity.StockTransferDeliveryItem for i, delivery := range deliveries { item := req.Deliveries[i] for _, prod := range item.Products { - detailID, ok := detailMap[uint64(prod.ProductID)] + detail, ok := detailMap[uint64(prod.ProductID)] if !ok { return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) } deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ StockTransferDeliveryId: delivery.Id, - StockTransferDetailId: detailID, + StockTransferDetailId: detail.Id, Quantity: prod.ProductQty, }) } @@ -280,69 +341,54 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } + // Execute FIFO operations for each product for _, product := range req.Products { - sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) + detail := detailMap[uint64(product.ProductID)] + + // Step 1: Consume stock from source warehouse (STOCK_TRANSFER_OUT) + consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ + UsableKey: "STOCK_TRANSFER_OUT", + UsableID: uint(detail.Id), + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + Quantity: product.ProductQty, + AllowPending: false, // Don't allow pending, must have actual stock + Tx: tx, + }) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") - } - if sourcePW.Quantity < product.ProductQty { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID)) - } - sourcePW.Quantity -= product.ProductQty - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil { - return err + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err)) } - decreaseLog := &entity.StockLog{ - Decrease: product.ProductQty, - Notes: "", - LoggableType: string(utils.StockLogTypeTransfer), - LoggableId: uint(entityTransfer.Id), - ProductWarehouseId: sourcePW.Id, - CreatedBy: actorID, - } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { - return err + // Update usage tracking fields for source warehouse + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "usage_qty": consumeResult.UsageQuantity, + "pending_qty": consumeResult.PendingQuantity, + }).Error; err != nil { + return fmt.Errorf("gagal update usage tracking: %w", err) } - destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( - c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), - ) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") - } - if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { - ctx := c.Context() - projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) - if err != nil { - return err - } - destPW = &entity.ProductWarehouse{ - ProductId: uint(product.ProductID), - WarehouseId: uint(req.DestinationWarehouseID), - Quantity: 0, - ProjectFlockKandangId: &projectFlockKandangID, - } - if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") - } + // Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN) + note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) + replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + StockableKey: "STOCK_TRANSFER_IN", + StockableID: uint(detail.Id), + ProductWarehouseID: uint(*detail.DestProductWarehouseID), + Quantity: product.ProductQty, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err)) } - destPW.Quantity += product.ProductQty - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { - return err - } - - increaseLog := &entity.StockLog{ - Increase: product.ProductQty, - LoggableType: string(utils.StockLogTypeTransfer), - LoggableId: uint(entityTransfer.Id), - Notes: "", - ProductWarehouseId: destPW.Id, - CreatedBy: actorID, - } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { - return err + // Update total tracking fields for destination warehouse + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }).Error; err != nil { + return fmt.Errorf("gagal update total tracking: %w", err) } } diff --git a/test/integration/inventory/transfers/transfer_fifo_integration_test.go b/test/integration/inventory/transfers/transfer_fifo_integration_test.go new file mode 100644 index 00000000..d9f127a1 --- /dev/null +++ b/test/integration/inventory/transfers/transfer_fifo_integration_test.go @@ -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) +}