mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
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.
This commit is contained in:
@@ -29,7 +29,7 @@ ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id
|
|||||||
|
|
||||||
-- Relasi ke product_warehouses
|
-- Relasi ke product_warehouses
|
||||||
ALTER TABLE project_chickins
|
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
|
-- Relasi ke users
|
||||||
ALTER TABLE project_chickins
|
ALTER TABLE project_chickins
|
||||||
|
|||||||
+42
@@ -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);
|
||||||
+83
@@ -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)';
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -7,12 +7,28 @@ type StockTransferDetail struct {
|
|||||||
Id uint64 `gorm:"primaryKey;autoIncrement"`
|
Id uint64 `gorm:"primaryKey;autoIncrement"`
|
||||||
StockTransferId uint64
|
StockTransferId uint64
|
||||||
ProductId uint64
|
ProductId uint64
|
||||||
Quantity float64
|
|
||||||
CreatedAt time.Time
|
// === FIFO FIELDS - SOURCE WAREHOUSE (Usable) ===
|
||||||
UpdatedAt time.Time
|
// Tracking stock yang DIAMBIL dari source warehouse
|
||||||
DeletedAt *time.Time `gorm:"index"`
|
SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"`
|
||||||
// Relations
|
UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil
|
||||||
StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"`
|
PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock)
|
||||||
Product *Product `gorm:"foreignKey:ProductId"`
|
|
||||||
DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"`
|
// === 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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"gorm.io/gorm"
|
"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"
|
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"
|
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"
|
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"
|
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
||||||
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
||||||
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
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{}
|
type AdjustmentModule struct{}
|
||||||
|
|
||||||
func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
|
func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
|
||||||
|
// Repositories
|
||||||
stockLogsRepo := rStockLogs.NewStockLogRepository(db)
|
stockLogsRepo := rStockLogs.NewStockLogRepository(db)
|
||||||
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
|
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
|
||||||
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
|
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
|
||||||
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
|
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
|
||||||
userRepo := rUser.NewUserRepository(db)
|
userRepo := rUser.NewUserRepository(db)
|
||||||
productRepo := rproduct.NewProductRepository(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)
|
userService := sUser.NewUserService(userRepo, validate)
|
||||||
|
|
||||||
AdjustmentRoutes(router, userService, adjustmentService)
|
AdjustmentRoutes(router, userService, adjustmentService)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
common "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
common "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
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"
|
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"
|
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"
|
productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
|
||||||
@@ -29,24 +30,37 @@ type AdjustmentService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type adjustmentService struct {
|
type adjustmentService struct {
|
||||||
Log *logrus.Logger
|
Log *logrus.Logger
|
||||||
Validate *validator.Validate
|
Validate *validator.Validate
|
||||||
StockLogsRepository stockLogsRepo.StockLogRepository
|
StockLogsRepository stockLogsRepo.StockLogRepository
|
||||||
WarehouseRepo warehouseRepo.WarehouseRepository
|
WarehouseRepo warehouseRepo.WarehouseRepository
|
||||||
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
|
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
|
||||||
ProductRepo productRepo.ProductRepository
|
ProductRepo productRepo.ProductRepository
|
||||||
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
|
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{
|
return &adjustmentService{
|
||||||
Log: utils.Log,
|
Log: utils.Log,
|
||||||
Validate: validate,
|
Validate: validate,
|
||||||
StockLogsRepository: stockLogsRepo,
|
StockLogsRepository: stockLogsRepo,
|
||||||
WarehouseRepo: warehouseRepo,
|
WarehouseRepo: warehouseRepo,
|
||||||
ProductWarehouseRepo: productWarehouseRepo,
|
ProductWarehouseRepo: productWarehouseRepo,
|
||||||
ProductRepo: productRepo,
|
ProductRepo: productRepo,
|
||||||
ProjectFlockKandangRepo: projectFlockKandangRepo,
|
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")
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create StockLog for history tracking
|
||||||
afterQuantity := productWarehouse.Quantity
|
afterQuantity := productWarehouse.Quantity
|
||||||
newLog := &entity.StockLog{
|
newLog := &entity.StockLog{
|
||||||
|
|
||||||
LoggableType: string(utils.StockLogTypeAdjustment),
|
LoggableType: string(utils.StockLogTypeAdjustment),
|
||||||
LoggableId: 0,
|
LoggableId: 0,
|
||||||
Notes: req.Note,
|
Notes: req.Note,
|
||||||
ProductWarehouseId: productWarehouse.Id,
|
ProductWarehouseId: productWarehouse.Id,
|
||||||
CreatedBy: actorID, // TODO: should Get from auth middleware
|
CreatedBy: actorID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
|
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
|
||||||
afterQuantity += req.Quantity
|
afterQuantity += req.Quantity
|
||||||
newLog.Increase = afterQuantity
|
newLog.Increase = afterQuantity
|
||||||
@@ -177,6 +192,57 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
|
|||||||
return err
|
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
|
productWarehouse.Quantity = afterQuantity
|
||||||
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil {
|
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)
|
s.Log.Errorf("Failed to update product warehouse quantity: %+v", err)
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ type ProductWarehousNestedDTO struct {
|
|||||||
|
|
||||||
type ProductWarehouseListDTO struct {
|
type ProductWarehouseListDTO struct {
|
||||||
ProductWarehouseRelationDTO
|
ProductWarehouseRelationDTO
|
||||||
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
|
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
|
||||||
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
|
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
|
||||||
CreatedUser *UserRelationDTO `json:"created_user,omitempty"`
|
ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedUser *UserRelationDTO `json:"created_user,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRelationDTO struct {
|
type UserRelationDTO struct {
|
||||||
@@ -71,6 +72,19 @@ type AreaRelationDTO struct {
|
|||||||
Name string `json:"name"`
|
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 ===
|
// === Mapper Functions ===
|
||||||
|
|
||||||
func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO {
|
func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO {
|
||||||
@@ -105,6 +119,12 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
|
|||||||
// Map Product relation jika ada
|
// Map Product relation jika ada
|
||||||
if e.Product.Id != 0 {
|
if e.Product.Id != 0 {
|
||||||
product := productDTO.ToProductRelationDTO(e.Product)
|
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
|
dto.Product = &product
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +159,26 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
|
|||||||
dto.Warehouse = &warehouse
|
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
|
// Map CreatedUser relation jika ada
|
||||||
// if e.CreatedUser.Id != 0 {
|
// if e.CreatedUser.Id != 0 {
|
||||||
// user := UserRelationDTO{
|
// user := UserRelationDTO{
|
||||||
|
|||||||
+23
-1
@@ -81,9 +81,29 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho
|
|||||||
|
|
||||||
func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) {
|
func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) {
|
||||||
var productWarehouse entity.ProductWarehouse
|
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 nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &productWarehouse, nil
|
return &productWarehouse, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +264,8 @@ func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id u
|
|||||||
Preload("Warehouse").
|
Preload("Warehouse").
|
||||||
Preload("Warehouse.Area").
|
Preload("Warehouse.Area").
|
||||||
Preload("Warehouse.Location").
|
Preload("Warehouse.Location").
|
||||||
|
Preload("ProjectFlockKandang").
|
||||||
|
Preload("ProjectFlockKandang.ProjectFlock").
|
||||||
First(&productWarehouse, id).Error
|
First(&productWarehouse, id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
|
|||||||
Preload("Warehouse.Location").
|
Preload("Warehouse.Location").
|
||||||
Preload("Warehouse.Area").
|
Preload("Warehouse.Area").
|
||||||
Preload("Warehouse.Kandang").
|
Preload("Warehouse.Kandang").
|
||||||
Preload("ProjectFlockKandang")
|
Preload("ProjectFlockKandang").
|
||||||
|
Preload("ProjectFlockKandang.ProjectFlock")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
|
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
|
|||||||
Id: d.Product.Id,
|
Id: d.Product.Id,
|
||||||
Name: d.Product.Name,
|
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,
|
Id: d.Product.Id,
|
||||||
Name: d.Product.Name,
|
Name: d.Product.Name,
|
||||||
},
|
},
|
||||||
Quantity: d.Quantity,
|
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
||||||
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
||||||
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
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{}
|
type TransferModule struct{}
|
||||||
@@ -34,13 +36,51 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
|||||||
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
|
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
|
||||||
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
|
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
|
||||||
documentRepo := commonRepo.NewDocumentRepository(db)
|
documentRepo := commonRepo.NewDocumentRepository(db)
|
||||||
|
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
|
||||||
|
|
||||||
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
|
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
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)
|
userService := sUser.NewUserService(userRepo, validate)
|
||||||
|
|
||||||
TransferRoutes(router, userService, transferService)
|
TransferRoutes(router, userService, transferService)
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ type transferService struct {
|
|||||||
WarehouseRepo warehouseRepo.WarehouseRepository
|
WarehouseRepo warehouseRepo.WarehouseRepository
|
||||||
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
|
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
|
||||||
DocumentSvc commonSvc.DocumentService
|
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{
|
return &transferService{
|
||||||
Log: utils.Log,
|
Log: utils.Log,
|
||||||
Validate: validate,
|
Validate: validate,
|
||||||
@@ -60,6 +61,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
|
|||||||
WarehouseRepo: warehouseRepo,
|
WarehouseRepo: warehouseRepo,
|
||||||
ProjectFlockKandangRepo: projectFlockKandangRepo,
|
ProjectFlockKandangRepo: projectFlockKandangRepo,
|
||||||
DocumentSvc: documentSvc,
|
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) {
|
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))
|
pwIDs := make([]uint, 0, len(req.Products))
|
||||||
|
|
||||||
for _, product := range req.Products {
|
for _, product := range req.Products {
|
||||||
@@ -152,6 +155,21 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
|
|||||||
return nil, err
|
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)
|
actorID, err := m.ActorIDFromContext(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -206,14 +224,62 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
|
|||||||
return err
|
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 {
|
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,
|
StockTransferId: entityTransfer.Id,
|
||||||
ProductId: uint64(product.ProductID),
|
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 {
|
if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -233,23 +299,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
detailMap := make(map[uint64]uint64)
|
|
||||||
for _, d := range details {
|
|
||||||
detailMap[d.ProductId] = d.Id
|
|
||||||
}
|
|
||||||
|
|
||||||
var deliveryItems []*entity.StockTransferDeliveryItem
|
var deliveryItems []*entity.StockTransferDeliveryItem
|
||||||
|
|
||||||
for i, delivery := range deliveries {
|
for i, delivery := range deliveries {
|
||||||
item := req.Deliveries[i]
|
item := req.Deliveries[i]
|
||||||
for _, prod := range item.Products {
|
for _, prod := range item.Products {
|
||||||
detailID, ok := detailMap[uint64(prod.ProductID)]
|
detail, ok := detailMap[uint64(prod.ProductID)]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID)
|
return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID)
|
||||||
}
|
}
|
||||||
deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{
|
deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{
|
||||||
StockTransferDeliveryId: delivery.Id,
|
StockTransferDeliveryId: delivery.Id,
|
||||||
StockTransferDetailId: detailID,
|
StockTransferDetailId: detail.Id,
|
||||||
Quantity: prod.ProductQty,
|
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 {
|
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 {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse")
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err))
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
decreaseLog := &entity.StockLog{
|
// Update usage tracking fields for source warehouse
|
||||||
Decrease: product.ProductQty,
|
if err := tx.Model(&entity.StockTransferDetail{}).
|
||||||
Notes: "",
|
Where("id = ?", detail.Id).
|
||||||
LoggableType: string(utils.StockLogTypeTransfer),
|
Updates(map[string]interface{}{
|
||||||
LoggableId: uint(entityTransfer.Id),
|
"usage_qty": consumeResult.UsageQuantity,
|
||||||
ProductWarehouseId: sourcePW.Id,
|
"pending_qty": consumeResult.PendingQuantity,
|
||||||
CreatedBy: actorID,
|
}).Error; err != nil {
|
||||||
}
|
return fmt.Errorf("gagal update usage tracking: %w", err)
|
||||||
if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
|
// Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN)
|
||||||
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
|
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
|
||||||
)
|
replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
StockableKey: "STOCK_TRANSFER_IN",
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse")
|
StockableID: uint(detail.Id),
|
||||||
}
|
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
|
||||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
Quantity: product.ProductQty,
|
||||||
ctx := c.Context()
|
Note: ¬e,
|
||||||
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID))
|
Tx: tx,
|
||||||
if err != nil {
|
})
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, 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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destPW.Quantity += product.ProductQty
|
// Update total tracking fields for destination warehouse
|
||||||
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil {
|
if err := tx.Model(&entity.StockTransferDetail{}).
|
||||||
return err
|
Where("id = ?", detail.Id).
|
||||||
}
|
Updates(map[string]interface{}{
|
||||||
|
"total_qty": replenishResult.AddedQuantity,
|
||||||
increaseLog := &entity.StockLog{
|
}).Error; err != nil {
|
||||||
Increase: product.ProductQty,
|
return fmt.Errorf("gagal update total tracking: %w", err)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user