unfinish: fifo system

This commit is contained in:
Hafizh A. Y
2025-11-14 13:56:51 +07:00
parent 26f9196876
commit 1156b376fc
12 changed files with 1829 additions and 17 deletions
@@ -0,0 +1,75 @@
package repository
import (
"context"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type StockAllocationRepository interface {
BaseRepository[entity.StockAllocation]
FindActiveByUsable(ctx context.Context, usableType string, usableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.StockAllocation, error)
ReleaseByUsable(ctx context.Context, usableType string, usableID uint, note *string, modifier func(*gorm.DB) *gorm.DB) error
}
type StockAllocationRepositoryImpl struct {
*BaseRepositoryImpl[entity.StockAllocation]
}
func NewStockAllocationRepository(db *gorm.DB) StockAllocationRepository {
return &StockAllocationRepositoryImpl{
BaseRepositoryImpl: NewBaseRepository[entity.StockAllocation](db),
}
}
func (r *StockAllocationRepositoryImpl) FindActiveByUsable(
ctx context.Context,
usableType string,
usableID uint,
modifier func(*gorm.DB) *gorm.DB,
) ([]entity.StockAllocation, error) {
var allocations []entity.StockAllocation
q := r.DB().WithContext(ctx).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
if modifier != nil {
q = modifier(q)
}
if err := q.Order("created_at ASC").Find(&allocations).Error; err != nil {
return nil, err
}
return allocations, nil
}
func (r *StockAllocationRepositoryImpl) ReleaseByUsable(
ctx context.Context,
usableType string,
usableID uint,
note *string,
modifier func(*gorm.DB) *gorm.DB,
) error {
now := time.Now()
updates := map[string]any{
"status": entity.StockAllocationStatusReleased,
"released_at": now,
}
if note != nil {
updates["note"] = *note
}
q := r.DB().WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
if modifier != nil {
q = modifier(q)
}
return q.Updates(updates).Error
}
@@ -0,0 +1,820 @@
package service
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/sirupsen/logrus"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gitlab.com/mbugroup/lti-api.git/internal/entities"
productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type FifoService interface {
RegisterStockable(cfg fifo.StockableConfig) error
RegisterUsable(cfg fifo.UsableConfig) error
Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error)
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
}
type fifoService struct {
db *gorm.DB
logger *logrus.Logger
allocations commonRepo.StockAllocationRepository
productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository
defaultOrderBy []string
pendingBatchPerUsable int
maxLotsPerStockable int
defaultAllocationNotes string
}
func NewFifoService(
db *gorm.DB,
allocations commonRepo.StockAllocationRepository,
productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository,
logger *logrus.Logger,
) FifoService {
if logger == nil {
logger = logrus.StandardLogger()
}
return &fifoService{
db: db,
logger: logger,
allocations: allocations,
productWarehouseRepo: productWarehouseRepo,
defaultOrderBy: []string{"created_at ASC", "id ASC"},
pendingBatchPerUsable: 25,
maxLotsPerStockable: 50,
}
}
func (s *fifoService) withTransaction(
ctx context.Context,
tx *gorm.DB,
fn func(*gorm.DB) error,
) error {
if tx != nil {
return fn(tx.WithContext(ctx))
}
return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
return fn(inner)
})
}
func (s *fifoService) txOrDB(tx, db *gorm.DB) *gorm.DB {
if tx != nil {
return tx
}
return db
}
func (s *fifoService) RegisterStockable(cfg fifo.StockableConfig) error {
return fifo.RegisterStockable(cfg)
}
func (s *fifoService) RegisterUsable(cfg fifo.UsableConfig) error {
return fifo.RegisterUsable(cfg)
}
type StockReplenishRequest struct {
StockableKey fifo.StockableKey
StockableID uint
ProductWarehouseID uint
Quantity float64
Note *string
Tx *gorm.DB
}
type PendingResolution struct {
UsableKey fifo.UsableKey
UsableID uint
Quantity float64
}
type StockReplenishResult struct {
AddedQuantity float64
PendingResolved []PendingResolution
RemainingPending float64
}
type StockConsumeRequest struct {
UsableKey fifo.UsableKey
UsableID uint
ProductWarehouseID uint
Quantity float64
AllowPending bool
Note *string
Tx *gorm.DB
}
type AllocationDetail struct {
StockableKey fifo.StockableKey
StockableID uint
Quantity float64
}
type StockConsumeResult struct {
RequestedQuantity float64
UsageQuantity float64
PendingQuantity float64
AddedAllocations []AllocationDetail
ReleasedQuantity float64
}
type StockReleaseRequest struct {
UsableKey fifo.UsableKey
UsableID uint
Reason *string
Tx *gorm.DB
}
func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return nil, errors.New("stockable key and id are required")
}
if req.ProductWarehouseID == 0 {
return nil, errors.New("product warehouse id is required")
}
if req.Quantity <= 0 {
return nil, errors.New("quantity must be greater than zero")
}
cfg, ok := fifo.Stockable(req.StockableKey)
if !ok {
return nil, fmt.Errorf("stockable %q is not registered", req.StockableKey)
}
result := &StockReplenishResult{
AddedQuantity: req.Quantity,
}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil {
return err
}
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
req.ProductWarehouseID: req.Quantity,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return err
}
resolved, err := s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
if err != nil {
return err
}
result.PendingResolved = resolved
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) {
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return nil, errors.New("usable key and id are required")
}
if req.Quantity < 0 {
return nil, errors.New("quantity must be zero or greater")
}
cfg, ok := fifo.Usable(req.UsableKey)
if !ok {
return nil, fmt.Errorf("usable %q is not registered", req.UsableKey)
}
result := &StockConsumeResult{
RequestedQuantity: req.Quantity,
}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID)
if err != nil {
return err
}
productWarehouseID := ctxRow.ProductWarehouseID
if productWarehouseID == 0 {
return fmt.Errorf("usable %q (id: %d) has no product warehouse reference", req.UsableKey, req.UsableID)
}
if req.ProductWarehouseID != 0 && req.ProductWarehouseID != productWarehouseID {
return fmt.Errorf("usable %q (id: %d) references product warehouse %d but %d was provided", req.UsableKey, req.UsableID, productWarehouseID, req.ProductWarehouseID)
}
currentUsage := ctxRow.UsageQty
currentPending := ctxRow.PendingQty
currentTotal := currentUsage + currentPending
delta := req.Quantity - currentTotal
var (
usageDelta float64
pendingDelta float64
addedAlloc []AllocationDetail
releasedAmount float64
)
switch {
case delta > 0:
allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta)
if err != nil {
return err
}
if allocationRes.pending > 0 && !req.AllowPending {
return fmt.Errorf("insufficient stock: requested %.3f, allocated %.3f", req.Quantity, currentUsage+allocationRes.allocated)
}
usageDelta += allocationRes.allocated
pendingDelta += allocationRes.pending
addedAlloc = allocationRes.allocations
if allocationRes.allocated > 0 {
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
productWarehouseID: -allocationRes.allocated,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return err
}
}
case delta < 0:
reductionTarget := -delta
if currentPending > 0 {
pendingReduction := math.Min(currentPending, reductionTarget)
if pendingReduction > 0 {
pendingDelta -= pendingReduction
reductionTarget -= pendingReduction
}
}
if reductionTarget > 0 {
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget)
if err != nil {
return err
}
if released+1e-6 < reductionTarget {
return fmt.Errorf("unable to release %.3f from usable %d, only %.3f available", reductionTarget, req.UsableID, released)
}
usageDelta -= released
releasedAmount = released
}
default:
// no change
}
if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil {
return err
}
result.AddedAllocations = addedAlloc
result.ReleasedQuantity = releasedAmount
result.UsageQuantity = currentUsage + usageDelta
result.PendingQuantity = currentPending + pendingDelta
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error {
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return errors.New("usable key and id are required")
}
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
cfg, ok := fifo.Usable(req.UsableKey)
if !ok {
return fmt.Errorf("usable %q is not registered", req.UsableKey)
}
ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID)
if err != nil {
return err
}
var usageDelta, pendingDelta float64
if ctxRow.UsageQty > 0 {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil {
return err
}
usageDelta -= ctxRow.UsageQty
}
if ctxRow.PendingQty > 0 {
pendingDelta -= ctxRow.PendingQty
}
if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil {
return err
}
return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
})
})
}
// --- helpers ---
type usableContextRow struct {
ProductWarehouseID uint
UsageQty float64
PendingQty float64
}
func (s *fifoService) loadUsableContext(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint) (*usableContextRow, error) {
var row usableContextRow
query := tx.Table(cfg.Table).
Select(fmt.Sprintf("%s AS product_warehouse_id, COALESCE(%s,0) AS usage_qty, COALESCE(%s,0) AS pending_qty", cfg.Columns.ProductWarehouseID, cfg.Columns.UsageQuantity, cfg.Columns.PendingQuantity)).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id).
Clauses(clause.Locking{Strength: "UPDATE"})
if cfg.Scope != nil {
query = cfg.Scope(query)
}
if err := query.Take(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("usable record %d not found", id)
}
return nil, err
}
return &row, nil
}
func (s *fifoService) incrementStockableQty(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error {
column := cfg.Columns.TotalQuantity
query := tx.Table(cfg.Table).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
updates := map[string]any{
column: gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty),
}
if cfg.Columns.TotalUsedQuantity != "" {
updates[cfg.Columns.TotalUsedQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0)", cfg.Columns.TotalUsedQuantity))
}
return query.Updates(updates).Error
}
func (s *fifoService) incrementStockableUsage(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error {
if qty == 0 {
return nil
}
column := cfg.Columns.TotalUsedQuantity
query := tx.Table(cfg.Table).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
return query.Update(column, gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty)).Error
}
type allocationOutcome struct {
allocated float64
pending float64
allocations []AllocationDetail
}
type stockLot struct {
StockableKey fifo.StockableKey
RecordID uint
AvailableQty float64
CreatedAt time.Time
}
func (s *fifoService) allocateFromStock(
ctx context.Context,
tx *gorm.DB,
productWarehouseID uint,
usableKey fifo.UsableKey,
usableID uint,
requestQty float64,
) (*allocationOutcome, error) {
lots, err := s.fetchStockLots(ctx, tx, productWarehouseID)
if err != nil {
return nil, err
}
if len(lots) == 0 {
return &allocationOutcome{pending: requestQty}, nil
}
var (
remaining = requestQty
applied float64
allocations []*entities.StockAllocation
allocationSummaries []AllocationDetail
usageAdjustments = make(map[fifo.StockableKey]map[uint]float64)
)
for _, lot := range lots {
if remaining <= 0 {
break
}
if lot.AvailableQty <= 0 {
continue
}
portion := lot.AvailableQty
if portion > remaining {
portion = remaining
}
applied += portion
remaining -= portion
allocationSummaries = append(allocationSummaries, AllocationDetail{
StockableKey: lot.StockableKey,
StockableID: lot.RecordID,
Quantity: portion,
})
allocations = append(allocations, &entities.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: lot.StockableKey.String(),
StockableId: lot.RecordID,
UsableType: usableKey.String(),
UsableId: usableID,
Qty: portion,
Status: entities.StockAllocationStatusActive,
})
if _, ok := usageAdjustments[lot.StockableKey]; !ok {
usageAdjustments[lot.StockableKey] = make(map[uint]float64)
}
usageAdjustments[lot.StockableKey][lot.RecordID] += portion
}
if len(allocations) > 0 {
if err := s.allocations.CreateMany(ctx, allocations, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return nil, err
}
for key, deltas := range usageAdjustments {
cfg, ok := fifo.Stockable(key)
if !ok {
continue
}
for id, qty := range deltas {
if err := s.incrementStockableUsage(ctx, tx, cfg, id, qty); err != nil {
return nil, err
}
}
}
}
return &allocationOutcome{
allocated: applied,
pending: remaining,
allocations: allocationSummaries,
}, nil
}
func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]stockLot, error) {
configs := fifo.Stockables()
if len(configs) == 0 {
return nil, nil
}
var lots []stockLot
for key, cfg := range configs {
selectStmt := fmt.Sprintf(
"%s AS id, %s AS available_qty, %s AS created_at",
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
cfg.Columns.CreatedAt,
)
var rows []struct {
ID uint
AvailableQty float64
CreatedAt time.Time
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > %s", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity))
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
query = query.Limit(s.maxLotsPerStockable)
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.AvailableQty <= 0 {
continue
}
lots = append(lots, stockLot{
StockableKey: key,
RecordID: row.ID,
AvailableQty: row.AvailableQty,
CreatedAt: row.CreatedAt,
})
}
}
if len(lots) == 0 {
return nil, nil
}
sort.SliceStable(lots, func(i, j int) bool {
if lots[i].CreatedAt.Equal(lots[j].CreatedAt) {
return lots[i].RecordID < lots[j].RecordID
}
return lots[i].CreatedAt.Before(lots[j].CreatedAt)
})
return lots, nil
}
func (s *fifoService) applyUsableDeltas(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint, usageDelta, pendingDelta float64) error {
if usageDelta == 0 && pendingDelta == 0 {
return nil
}
updates := map[string]any{}
if usageDelta != 0 {
updates[cfg.Columns.UsageQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.UsageQuantity), usageDelta)
}
if pendingDelta != 0 {
updates[cfg.Columns.PendingQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.PendingQuantity), pendingDelta)
}
query := tx.Table(cfg.Table).Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
return query.Updates(updates).Error
}
type pendingCandidate struct {
UsableKey fifo.UsableKey
Config fifo.UsableConfig
UsableID uint
Pending float64
CreatedAt time.Time
}
func (s *fifoService) resolvePendingForWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]PendingResolution, error) {
candidates, err := s.fetchPendingCandidates(ctx, tx, productWarehouseID)
if err != nil {
return nil, err
}
if len(candidates) == 0 {
return nil, nil
}
var resolutions []PendingResolution
for _, candidate := range candidates {
if candidate.Pending <= 0 {
continue
}
outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending)
if err != nil {
return nil, err
}
if outcome.allocated <= 0 {
break
}
if err := s.applyUsableDeltas(ctx, tx, candidate.Config, candidate.UsableID, outcome.allocated, -outcome.allocated); err != nil {
return nil, err
}
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
productWarehouseID: -outcome.allocated,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return nil, err
}
resolutions = append(resolutions, PendingResolution{
UsableKey: candidate.UsableKey,
UsableID: candidate.UsableID,
Quantity: outcome.allocated,
})
if outcome.pending > 0 {
// No more stock available for this warehouse at the moment.
break
}
}
return resolutions, nil
}
func (s *fifoService) releaseUsagePortion(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
target float64,
) (float64, error) {
if target <= 0 {
return 0, nil
}
allocations, err := s.allocations.FindActiveByUsable(ctx, usableKey.String(), usableID, func(db *gorm.DB) *gorm.DB {
target := s.txOrDB(tx, db)
return target.Clauses(clause.Locking{Strength: "UPDATE"})
})
if err != nil {
return 0, err
}
if len(allocations) == 0 {
return 0, nil
}
var (
remaining = target
totalReleased float64
warehouseAdjustments = make(map[uint]float64)
stockableAdjustments = make(map[fifo.StockableKey]map[uint]float64)
)
now := time.Now()
for i := len(allocations) - 1; i >= 0 && remaining > 0; i-- {
allocation := allocations[i]
releaseAmt := allocation.Qty
if releaseAmt > remaining {
releaseAmt = remaining
}
remaining -= releaseAmt
totalReleased += releaseAmt
warehouseAdjustments[allocation.ProductWarehouseId] += releaseAmt
key := fifo.StockableKey(allocation.StockableType)
if _, ok := stockableAdjustments[key]; !ok {
stockableAdjustments[key] = make(map[uint]float64)
}
stockableAdjustments[key][allocation.StockableId] += releaseAmt
if releaseAmt == allocation.Qty {
if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{
"status": entities.StockAllocationStatusReleased,
"released_at": now,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return 0, err
}
} else {
if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{
"quantity": allocation.Qty - releaseAmt,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return 0, err
}
}
}
if totalReleased == 0 {
return 0, nil
}
for key, deltas := range stockableAdjustments {
cfg, ok := fifo.Stockable(key)
if !ok {
continue
}
for id, qty := range deltas {
if err := s.incrementStockableUsage(ctx, tx, cfg, id, -qty); err != nil {
return 0, err
}
}
}
if len(warehouseAdjustments) > 0 {
if err := s.productWarehouseRepo.AdjustQuantities(ctx, warehouseAdjustments, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return 0, err
}
for warehouseID := range warehouseAdjustments {
if _, err := s.resolvePendingForWarehouse(ctx, tx, warehouseID); err != nil {
return 0, err
}
}
}
return totalReleased, nil
}
func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]pendingCandidate, error) {
configs := fifo.Usables()
if len(configs) == 0 {
return nil, nil
}
var candidates []pendingCandidate
for key, cfg := range configs {
selectStmt := fmt.Sprintf(
"%s AS id, %s AS pending_qty, %s AS created_at",
cfg.Columns.ID,
cfg.Columns.PendingQuantity,
cfg.Columns.CreatedAt,
)
var rows []struct {
ID uint
Pending float64
CreatedAt time.Time
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: row.CreatedAt,
})
}
}
if len(candidates) == 0 {
return nil, nil
}
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].CreatedAt.Equal(candidates[j].CreatedAt) {
return candidates[i].UsableID < candidates[j].UsableID
}
return candidates[i].CreatedAt.Before(candidates[j].CreatedAt)
})
return candidates, nil
}
func (s *fifoService) orderClauses(custom []string) []string {
if len(custom) > 0 {
return custom
}
return s.defaultOrderBy
}
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS stock_allocations_released_at_idx;
DROP INDEX IF EXISTS stock_allocations_status_idx;
DROP INDEX IF EXISTS stock_allocations_usage_lookup;
DROP INDEX IF EXISTS stock_allocations_lookup;
DROP INDEX IF EXISTS stock_allocations_product_warehouse_id_idx;
DROP TABLE IF EXISTS stock_allocations;
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS stock_allocations (
id BIGSERIAL PRIMARY KEY,
product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses(id),
stockable_type VARCHAR(100) NOT NULL,
stockable_id BIGINT NOT NULL,
usable_type VARCHAR(100) NOT NULL,
usable_id BIGINT NOT NULL,
qty NUMERIC(15,3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
note TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
released_at TIMESTAMPTZ NULL,
deleted_at TIMESTAMPTZ NULL
);
CREATE INDEX IF NOT EXISTS stock_allocations_product_warehouse_id_idx
ON stock_allocations (product_warehouse_id);
CREATE INDEX IF NOT EXISTS stock_allocations_lookup
ON stock_allocations (stockable_type, stockable_id);
CREATE INDEX IF NOT EXISTS stock_allocations_usage_lookup
ON stock_allocations (usable_type, usable_id);
CREATE INDEX IF NOT EXISTS stock_allocations_status_idx
ON stock_allocations (status);
CREATE INDEX IF NOT EXISTS stock_allocations_released_at_idx
ON stock_allocations (released_at);
+33
View File
@@ -0,0 +1,33 @@
package entities
import (
"time"
"gorm.io/gorm"
)
const (
StockAllocationStatusPending = "PENDING"
StockAllocationStatusActive = "ACTIVE"
StockAllocationStatusReleased = "RELEASED"
)
// StockAllocation links a usable record (consumption) with an incoming stock record.
// The combination lets us trace FIFO deductions while keeping each module focused on its own fields.
type StockAllocation struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"not null;index"`
StockableType string `gorm:"size:100;not null;index:stock_allocations_lookup,priority:1"`
StockableId uint `gorm:"not null;index:stock_allocations_lookup,priority:2"`
UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"`
UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"`
Qty float64 `gorm:"type:numeric(15,3);not null"`
Status string `gorm:"size:20;not null;default:ACTIVE"`
Note *string `gorm:"type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ReleasedAt *time.Time `gorm:"index"`
DeletedAt gorm.DeletedAt `gorm:"index"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
}
@@ -2,6 +2,7 @@ package recordings
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -14,6 +15,7 @@ import (
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
@@ -26,6 +28,25 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingStock,
Table: "recording_stocks",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording usable workflow: %v", err))
}
}
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil {
@@ -41,6 +62,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockPopulationRepo,
approvalRepo,
approvalService,
fifoService,
validate,
)
userService := sUser.NewUserService(userRepo, validate)
@@ -25,6 +25,7 @@ type RecordingRepository interface {
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
DeleteStocks(tx *gorm.DB, recordingID uint) error
ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error)
UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error
CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error
DeleteDepletions(tx *gorm.DB, recordingID uint) error
@@ -120,6 +121,15 @@ func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]e
return items, nil
}
func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error {
return tx.Model(&entity.RecordingStock{}).
Where("id = ?", stockID).
Updates(map[string]any{
"usage_qty": usageQty,
"pending_qty": pendingQty,
}).Error
}
func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error {
if len(depletions) == 0 {
return nil
@@ -17,6 +17,7 @@ import (
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
"github.com/go-playground/validator/v10"
@@ -36,6 +37,13 @@ type RecordingService interface {
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error)
}
type RecordingFIFOIntegrationService interface {
ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error
ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error
}
var recordingStockUsableKey = fifo.UsableKeyRecordingStock
type recordingService struct {
Log *logrus.Logger
Validate *validator.Validate
@@ -45,6 +53,7 @@ type recordingService struct {
ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
ApprovalRepo commonRepo.ApprovalRepository
ApprovalSvc commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
}
func NewRecordingService(
@@ -54,6 +63,7 @@ func NewRecordingService(
projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository,
approvalRepo commonRepo.ApprovalRepository,
approvalSvc commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService,
validate *validator.Validate,
) RecordingService {
return &recordingService{
@@ -65,6 +75,20 @@ func NewRecordingService(
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
ApprovalRepo: approvalRepo,
ApprovalSvc: approvalSvc,
FifoSvc: fifoSvc,
}
}
func NewRecordingFIFOIntegrationService(
repo repository.RecordingRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
fifoSvc commonSvc.FifoService,
) RecordingFIFOIntegrationService {
return &recordingService{
Log: utils.Log,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
FifoSvc: fifoSvc,
}
}
@@ -219,6 +243,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return err
}
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
return err
}
mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions)
if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil {
s.Log.Errorf("Failed to persist depletions: %+v", err)
@@ -231,7 +259,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return err
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedStocks, nil, mappedEggs)); err != nil {
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, nil, nil, mappedEggs)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses: %+v", err)
return err
}
@@ -313,6 +341,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err
}
if err := s.releaseRecordingStocks(ctx, tx, existingStocks); err != nil {
return err
}
if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear stocks: %+v", err)
return err
@@ -324,8 +356,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingStocks, mappedStocks, nil, nil)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses for stocks: %+v", err)
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
return err
}
}
@@ -610,7 +641,11 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldStocks, nil, oldEggs, nil)); err != nil {
if err := s.releaseRecordingStocks(ctx, tx, oldStocks); err != nil {
return err
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, nil, nil, oldEggs, nil)); err != nil {
return err
}
@@ -665,6 +700,77 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v
return nil
}
func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
if len(stocks) == 0 || s.FifoSvc == nil {
return nil
}
for _, stock := range stocks {
if stock.Id == 0 {
continue
}
var desired float64
if stock.UsageQty != nil {
desired = *stock.UsageQty
}
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
ProductWarehouseID: stock.ProductWarehouseId,
Quantity: desired,
AllowPending: true,
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err)
return err
}
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err
}
}
return nil
}
func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
return s.consumeRecordingStocks(ctx, tx, stocks)
}
func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
if len(stocks) == 0 || s.FifoSvc == nil {
return nil
}
for _, stock := range stocks {
if stock.Id == 0 {
continue
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err)
return err
}
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err
}
}
return nil
}
func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
return s.releaseRecordingStocks(ctx, tx, stocks)
}
func buildWarehouseDeltas(
oldDepletions, newDepletions []entity.RecordingDepletion,
oldStocks, newStocks []entity.RecordingStock,
@@ -677,12 +783,6 @@ func buildWarehouseDeltas(
for _, item := range newDepletions {
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, item.Qty)
}
for _, item := range oldStocks {
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, usageQtyValue(item.UsageQty))
}
for _, item := range newStocks {
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -usageQtyValue(item.UsageQty))
}
for _, item := range oldEggs {
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty))
}
@@ -692,13 +792,6 @@ func buildWarehouseDeltas(
return deltas
}
func usageQtyValue(val *float64) float64 {
if val == nil {
return 0
}
return *val
}
func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) {
if id == 0 || value == 0 {
return
+67
View File
@@ -0,0 +1,67 @@
# Mesin Stok FIFO
Utilitas FIFO bersifat reusable dan dibagi menjadi dua lapis:
1. **Registry (`internal/utils/fifo`)** mendeklarasikan tabel mana yang bersifat `Stockable` (sumber stok) atau `Usable` (pemakai stok). Setiap modul cukup menyebutkan nama tabel dan kolom wajib:
- Stockable: `id`, `product_warehouse_id`, `total_qty`, `total_used_qty`, `created_at`
- Usable: `id`, `product_warehouse_id`, `usage_qty`, `pending_qty`, `created_at`
2. **Service (`internal/common/service/common.fifo.service.go`)** memakai registry tersebut untuk:
- Menambah stok baru (`Replenish`).
- Menyinkronkan total pemakaian (`Consume`). Method ini idempotent: panggil dengan *total kuantitas yang diinginkan* (mis. saat create/update/delete). Service menghitung selisih terhadap `usage_qty + pending_qty`, kemudian otomatis mengalokasikan tambahan atau melepaskan selisihnya.
- Membatalkan pemakaian (`ReleaseUsage`) yang mengembalikan stok lalu memicu alokasi ulang ke antrian pending.
- Baik `Replenish` maupun pelepasan stok akan menjalankan `resolvePendingForWarehouse`, sehingga pending tertua langsung terisi ketika stok tersedia.
## Registrasi tabel
```go
import (
commonservice "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
)
func init() {
fifoSvc := commonservice.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
fifoSvc.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKey("PURCHASE_DETAIL"),
Table: "purchase_details",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used_qty",
CreatedAt: "created_at",
},
})
fifoSvc.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKey("RECORDING_STOCK"),
Table: "recording_stocks",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
})
}
```
Each registration optionally accepts an order clause or base scope (e.g. to exclude drafts).
Setiap registrasi bisa diberi klausa urutan atau scope dasar (mis. untuk mengecualikan draft).
## Menggunakan service di modul
1. **Saat stok masuk** (mis. purchase selesai): panggil `fifoSvc.Replenish(...)` dengan key stockable, id record, id product warehouse, dan kuantitas yang baru tersedia. Service akan:
- Menambah `total_qty` pada tabel stockable,
- Menambah `product_warehouses.quantity`,
- Mencoba membersihkan `pending_qty` dari semua usable yang terdaftar (sesuai urutan FIFO).
2. **Saat modul memakai stok** (recording, marketing, dsb.) panggil `fifoSvc.Consume(...)` dengan total qty terbaru.
- Jika qty baru lebih besar, service mengambil stok FIFO dan menambah `usage_qty`; kekurangan dicatat sebagai `pending_qty`.
- Jika qty baru lebih kecil, service otomatis menurunkan `pending_qty` lebih dulu, lalu melepaskan alokasi aktif (stok kembali ke gudang) dan langsung dipakai untuk mengisi pending milik entitas lain.
- Hapus data? panggil `Consume` dengan qty 0 atau gunakan `ReleaseUsage`.
3. **Jika dibatalkan penuh**: `fifoSvc.ReleaseUsage(...)` mengosongkan `usage_qty/pending_qty` dan menandai baris pivot sebagai `RELEASED`.
Tabel pivot (`stock_allocations`) menyimpan asal pemakaian secara presisi, sehingga audit trail dan rollback stok menjadi deterministik.
+5
View File
@@ -0,0 +1,5 @@
package fifo
const (
UsableKeyRecordingStock UsableKey = "RECORDING_STOCK"
)
+204
View File
@@ -0,0 +1,204 @@
package fifo
import (
"errors"
"fmt"
"strings"
"sync"
"gorm.io/gorm"
)
// QueryScope allows callers to inject custom query modifiers (preloads, filters, etc).
type QueryScope func(*gorm.DB) *gorm.DB
type StockableKey string
type UsableKey string
func (k StockableKey) String() string {
return string(k)
}
func (k UsableKey) String() string {
return string(k)
}
// StockableColumns describes the minimum columns required for a stock-bearing row.
type StockableColumns struct {
ID string
ProductWarehouseID string
TotalQuantity string
TotalUsedQuantity string
CreatedAt string
}
// UsableColumns describes the required columns for rows that consume stock.
type UsableColumns struct {
ID string
ProductWarehouseID string
UsageQuantity string
PendingQuantity string
CreatedAt string
}
// StockableConfig registers a table that introduces stock into the system (purchases, transfers, etc).
type StockableConfig struct {
Key StockableKey
Table string
Columns StockableColumns
// OrderBy accepts raw column expressions, evaluated in-order (e.g. []string{"created_at ASC", "id ASC"}).
OrderBy []string
// Scope lets a module append base filters (e.g. exclude drafts).
Scope QueryScope
}
// UsableConfig registers a table that consumes stock (recordings, adjustments, sales, etc).
type UsableConfig struct {
Key UsableKey
Table string
Columns UsableColumns
OrderBy []string
Scope QueryScope
}
var (
stockableRegistry = make(map[StockableKey]StockableConfig)
usableRegistry = make(map[UsableKey]UsableConfig)
registryMu sync.RWMutex
)
// RegisterStockable stores the configuration so services can perform FIFO operations generically.
func RegisterStockable(cfg StockableConfig) error {
if err := validateStockableConfig(cfg); err != nil {
return err
}
registryMu.Lock()
defer registryMu.Unlock()
key := StockableKey(strings.TrimSpace(cfg.Key.String()))
if _, exists := stockableRegistry[key]; exists {
return fmt.Errorf("stockable key %q already registered", key)
}
stockableRegistry[key] = cfg
return nil
}
// RegisterUsable stores the configuration for stock-consuming tables.
func RegisterUsable(cfg UsableConfig) error {
if err := validateUsableConfig(cfg); err != nil {
return err
}
registryMu.Lock()
defer registryMu.Unlock()
key := UsableKey(strings.TrimSpace(cfg.Key.String()))
if _, exists := usableRegistry[key]; exists {
return fmt.Errorf("usable key %q already registered", key)
}
usableRegistry[key] = cfg
return nil
}
// Stockable returns the registered configuration for the key (if any).
func Stockable(key StockableKey) (StockableConfig, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
cfg, ok := stockableRegistry[key]
return cfg, ok
}
// Usable returns the registered configuration for the key (if any).
func Usable(key UsableKey) (UsableConfig, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
cfg, ok := usableRegistry[key]
return cfg, ok
}
// Stockables exposes a copy of the current registry (useful for iterating pending requests).
func Stockables() map[StockableKey]StockableConfig {
registryMu.RLock()
defer registryMu.RUnlock()
if len(stockableRegistry) == 0 {
return nil
}
result := make(map[StockableKey]StockableConfig, len(stockableRegistry))
for key, cfg := range stockableRegistry {
result[key] = cfg
}
return result
}
// Usables exposes a copy of the usable registry.
func Usables() map[UsableKey]UsableConfig {
registryMu.RLock()
defer registryMu.RUnlock()
if len(usableRegistry) == 0 {
return nil
}
result := make(map[UsableKey]UsableConfig, len(usableRegistry))
for key, cfg := range usableRegistry {
result[key] = cfg
}
return result
}
func validateStockableConfig(cfg StockableConfig) error {
if strings.TrimSpace(cfg.Key.String()) == "" {
return errors.New("stockable key is required")
}
if strings.TrimSpace(cfg.Table) == "" {
return fmt.Errorf("table name is required for stockable %q", cfg.Key)
}
cols := cfg.Columns
switch {
case strings.TrimSpace(cols.ID) == "":
return fmt.Errorf("column id is required for stockable %q", cfg.Key)
case strings.TrimSpace(cols.ProductWarehouseID) == "":
return fmt.Errorf("column product warehouse id is required for stockable %q", cfg.Key)
case strings.TrimSpace(cols.TotalQuantity) == "":
return fmt.Errorf("column total quantity is required for stockable %q", cfg.Key)
case strings.TrimSpace(cols.TotalUsedQuantity) == "":
return fmt.Errorf("column total used quantity is required for stockable %q", cfg.Key)
case strings.TrimSpace(cols.CreatedAt) == "":
return fmt.Errorf("column created_at is required for stockable %q", cfg.Key)
}
return nil
}
func validateUsableConfig(cfg UsableConfig) error {
if strings.TrimSpace(cfg.Key.String()) == "" {
return errors.New("usable key is required")
}
if strings.TrimSpace(cfg.Table) == "" {
return fmt.Errorf("table name is required for usable %q", cfg.Key)
}
cols := cfg.Columns
switch {
case strings.TrimSpace(cols.ID) == "":
return fmt.Errorf("column id is required for usable %q", cfg.Key)
case strings.TrimSpace(cols.ProductWarehouseID) == "":
return fmt.Errorf("column product warehouse id is required for usable %q", cfg.Key)
case strings.TrimSpace(cols.UsageQuantity) == "":
return fmt.Errorf("column usage quantity is required for usable %q", cfg.Key)
case strings.TrimSpace(cols.PendingQuantity) == "":
return fmt.Errorf("column pending quantity is required for usable %q", cfg.Key)
case strings.TrimSpace(cols.CreatedAt) == "":
return fmt.Errorf("column created_at is required for usable %q", cfg.Key)
}
return nil
}
@@ -0,0 +1,446 @@
package test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
servicePkg "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
)
func TestRecordingFIFO_CreatePendingWithoutStock(t *testing.T) {
db, svc, _, _ := setupRecordingFIFOTableTest(t)
ctx := context.Background()
recordingID := uint(1)
productWarehouse := createProductWarehouseRow(t, db, 0)
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks (pending) failed: %v", err)
}
updated := fetchRecordingStock(t, db, stock.Id)
assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should remain zero when no stock is available")
assertFloatEqual(t, 10, updated.PendingQty, "pending_qty should capture the entire request")
assertWarehouseQuantity(t, db, productWarehouse.Id, 0)
assertAllocationCount(t, db, 0)
assertAllocationCount(t, db, 0)
}
func TestRecordingFIFO_EditReallocatesUsage(t *testing.T) {
db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t)
ctx := context.Background()
recordingID := uint(1)
productWarehouse := createProductWarehouseRow(t, db, 0)
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
lot := createStockLot(t, db, productWarehouse.Id)
if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: stockableKey,
StockableID: lot.Id,
ProductWarehouseID: productWarehouse.Id,
Quantity: 12,
}); err != nil {
t.Fatalf("replenish failed: %v", err)
}
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks (initial) failed: %v", err)
}
assertWarehouseQuantity(t, db, productWarehouse.Id, 2)
desired := 4.0
stock.UsageQty = &desired
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks (edit) failed: %v", err)
}
updated := fetchRecordingStock(t, db, stock.Id)
assertFloatEqual(t, 4, updated.UsageQty, "usage_qty should reflect edited request")
assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should remain zero after downsize")
assertWarehouseQuantity(t, db, productWarehouse.Id, 8)
alloc := fetchSingleAllocation(t, db, stock.Id)
if alloc.Status != entity.StockAllocationStatusActive {
t.Fatalf("expected ACTIVE allocation, got %s", alloc.Status)
}
if mathAbs(alloc.Qty-4) > 1e-6 {
t.Fatalf("expected allocation qty 4, got %.3f", alloc.Qty)
}
}
func TestRecordingFIFO_DeleteReleasesStock(t *testing.T) {
db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t)
ctx := context.Background()
recordingID := uint(1)
productWarehouse := createProductWarehouseRow(t, db, 0)
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
lot := createStockLot(t, db, productWarehouse.Id)
if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: stockableKey,
StockableID: lot.Id,
ProductWarehouseID: productWarehouse.Id,
Quantity: 10,
}); err != nil {
t.Fatalf("replenish failed: %v", err)
}
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks failed: %v", err)
}
if err := svc.ReleaseRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("releaseRecordingStocks failed: %v", err)
}
updated := fetchRecordingStock(t, db, stock.Id)
assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should be cleared after delete")
assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should be cleared after delete")
assertWarehouseQuantity(t, db, productWarehouse.Id, 10)
alloc := fetchSingleAllocation(t, db, stock.Id)
if alloc.Status != entity.StockAllocationStatusReleased {
t.Fatalf("expected allocation to be released, got %s", alloc.Status)
}
}
// --- helpers ----------------------------------------------------------------
type recordingStockTable struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
UsageQty *float64 `gorm:"column:usage_qty"`
PendingQty *float64 `gorm:"column:pending_qty"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (recordingStockTable) TableName() string { return "recording_stocks" }
type productWarehouseTable struct {
Id uint `gorm:"primaryKey"`
ProductId uint `gorm:"column:product_id"`
WarehouseId uint `gorm:"column:warehouse_id"`
Quantity float64 `gorm:"column:quantity"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (productWarehouseTable) TableName() string { return "product_warehouses" }
type stockAllocationTable struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"not null"`
StockableType string `gorm:"size:100"`
StockableId uint
UsableType string `gorm:"size:100"`
UsableId uint
Qty float64 `gorm:"column:qty"`
Status string `gorm:"size:20"`
Note *string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
ReleasedAt *time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (stockAllocationTable) TableName() string { return "stock_allocations" }
type testStockSource struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
TotalQty float64 `gorm:"column:total_qty"`
TotalUsedQty float64 `gorm:"column:total_used_qty"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time
}
func (testStockSource) TableName() string { return "test_fifo_stockables" }
func setupRecordingFIFOTableTest(t *testing.T) (*gorm.DB, servicePkg.RecordingFIFOIntegrationService, commonSvc.FifoService, fifo.StockableKey) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&recordingStockTable{},
&productWarehouseTable{},
&stockAllocationTable{},
&testStockSource{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
if err := db.AutoMigrate(
&entity.ProductWarehouse{},
&entity.StockAllocation{},
&entity.RecordingStock{},
); err != nil {
t.Fatalf("auto migrate entities: %v", err)
}
stockAllocRepo := newFifoTestStockAllocationRepo(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
registerRecordingUsable(t, fifoSvc)
key := fifo.StockableKey(fmt.Sprintf("TEST_STOCKABLE_%s_%d", sanitizeKey(t.Name()), time.Now().UnixNano()))
if err := fifoSvc.RegisterStockable(fifo.StockableConfig{
Key: key,
Table: "test_fifo_stockables",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used_qty",
CreatedAt: "created_at",
},
}); err != nil {
t.Fatalf("register stockable: %v", err)
}
svc := servicePkg.NewRecordingFIFOIntegrationService(
recordingRepo.NewRecordingRepository(db),
productWarehouseRepo,
fifoSvc,
)
return db, svc, fifoSvc, key
}
func registerRecordingUsable(t *testing.T, fifoSvc commonSvc.FifoService) {
t.Helper()
err := fifoSvc.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingStock,
Table: "recording_stocks",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
})
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
t.Fatalf("register usable: %v", err)
}
if _, ok := fifo.Usable(fifo.UsableKeyRecordingStock); !ok {
t.Fatal("recording stock usable key not registered")
}
}
func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse {
t.Helper()
pw := entity.ProductWarehouse{
ProductId: 1,
WarehouseId: 1,
Quantity: qty,
CreatedBy: 1,
}
if err := db.Create(&pw).Error; err != nil {
t.Fatalf("create product warehouse: %v", err)
}
return pw
}
func createRecordingStockRow(t *testing.T, db *gorm.DB, recordingID, productWarehouseID uint, desired float64) entity.RecordingStock {
t.Helper()
stock := entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: productWarehouseID,
UsageQty: floatPtr(0),
PendingQty: floatPtr(0),
}
if err := db.Create(&stock).Error; err != nil {
t.Fatalf("create recording stock: %v", err)
}
stock.UsageQty = floatPtr(desired)
return stock
}
func createStockLot(t *testing.T, db *gorm.DB, productWarehouseID uint) testStockSource {
t.Helper()
lot := testStockSource{
ProductWarehouseId: productWarehouseID,
CreatedAt: time.Now(),
}
if err := db.Create(&lot).Error; err != nil {
t.Fatalf("create stock lot: %v", err)
}
return lot
}
func fetchRecordingStock(t *testing.T, db *gorm.DB, id uint) entity.RecordingStock {
t.Helper()
var stock entity.RecordingStock
if err := db.First(&stock, id).Error; err != nil {
t.Fatalf("fetch recording stock: %v", err)
}
return stock
}
func fetchSingleAllocation(t *testing.T, db *gorm.DB, usableID uint) entity.StockAllocation {
t.Helper()
var alloc entity.StockAllocation
if err := db.Where("usable_id = ?", usableID).Order("created_at ASC").First(&alloc).Error; err != nil {
t.Fatalf("fetch allocation: %v", err)
}
return alloc
}
func assertAllocationCount(t *testing.T, db *gorm.DB, expected int64) {
t.Helper()
var count int64
if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil {
t.Fatalf("count allocations: %v", err)
}
if count != expected {
t.Fatalf("expected %d allocations, got %d", expected, count)
}
}
func assertWarehouseQuantity(t *testing.T, db *gorm.DB, id uint, expected float64) {
t.Helper()
var pw entity.ProductWarehouse
if err := db.First(&pw, id).Error; err != nil {
t.Fatalf("fetch product warehouse: %v", err)
}
if mathAbs(pw.Quantity-expected) > 1e-6 {
t.Fatalf("expected warehouse quantity %.3f, got %.3f", expected, pw.Quantity)
}
}
func assertFloatEqual(t *testing.T, expected float64, value *float64, msg string) {
t.Helper()
if value == nil {
t.Fatalf("expected %s %.3f, got nil", msg, expected)
}
if mathAbs(*value-expected) > 1e-6 {
t.Fatalf("%s: expected %.3f, got %.3f", msg, expected, *value)
}
}
func floatPtr(v float64) *float64 {
p := new(float64)
*p = v
return p
}
func mathAbs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func sanitizeKey(name string) string {
if name == "" {
return "CASE"
}
clean := strings.Map(func(r rune) rune {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
if r >= 'a' && r <= 'z' {
return r - 32
}
return '_'
}, name)
return clean
}
type fifoTestStockAllocationRepo struct {
commonRepo.StockAllocationRepository
db *gorm.DB
}
func newFifoTestStockAllocationRepo(db *gorm.DB) commonRepo.StockAllocationRepository {
return &fifoTestStockAllocationRepo{
StockAllocationRepository: commonRepo.NewStockAllocationRepository(db),
db: db,
}
}
func (r *fifoTestStockAllocationRepo) PatchOne(
ctx context.Context,
id uint,
updates map[string]any,
modifier func(*gorm.DB) *gorm.DB,
) error {
base := r.db
setClauses := make([]string, 0, len(updates))
args := make([]any, 0, len(updates)+1)
for column, value := range updates {
colName := column
if strings.EqualFold(column, "quantity") {
colName = "qty"
}
setClauses = append(setClauses, fmt.Sprintf("%s = ?", colName))
args = append(args, value)
}
args = append(args, id)
sql := fmt.Sprintf("UPDATE stock_allocations SET %s WHERE id = ?", strings.Join(setClauses, ", "))
result := base.Exec(sql, args...)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *fifoTestStockAllocationRepo) ReleaseByUsable(
ctx context.Context,
usableType string,
usableID uint,
note *string,
modifier func(*gorm.DB) *gorm.DB,
) error {
base := r.db
setClause := "status = ?, released_at = ?"
args := []any{entity.StockAllocationStatusReleased, time.Now()}
if note != nil {
setClause += ", note = ?"
args = append(args, *note)
}
args = append(args, usableType, usableID, entity.StockAllocationStatusActive)
sql := fmt.Sprintf(
"UPDATE stock_allocations SET %s WHERE usable_type = ? AND usable_id = ? AND status = ?",
setClause,
)
result := base.Exec(sql, args...)
return result.Error
}