diff --git a/internal/common/repository/common.stock_allocation.repository.go b/internal/common/repository/common.stock_allocation.repository.go new file mode 100644 index 00000000..38b1a93b --- /dev/null +++ b/internal/common/repository/common.stock_allocation.repository.go @@ -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 +} diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go new file mode 100644 index 00000000..e3b80268 --- /dev/null +++ b/internal/common/service/common.fifo.service.go @@ -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 +} diff --git a/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql b/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql new file mode 100644 index 00000000..955610e9 --- /dev/null +++ b/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql @@ -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; diff --git a/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql b/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql new file mode 100644 index 00000000..b2a8b053 --- /dev/null +++ b/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql @@ -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); diff --git a/internal/entities/stock_allocation.go b/internal/entities/stock_allocation.go new file mode 100644 index 00000000..614762a1 --- /dev/null +++ b/internal/entities/stock_allocation.go @@ -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"` +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index ff6b4ea0..341031e1 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -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) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index d9512edd..5feb8d6b 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -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 diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index b31a90c0..d52feab7 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -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 } @@ -344,6 +372,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 @@ -355,8 +387,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 } } @@ -685,7 +716,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 } @@ -740,6 +775,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, @@ -752,12 +858,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)) } @@ -767,13 +867,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 diff --git a/internal/utils/fifo/README.md b/internal/utils/fifo/README.md new file mode 100644 index 00000000..86f2af6d --- /dev/null +++ b/internal/utils/fifo/README.md @@ -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. diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go new file mode 100644 index 00000000..c47d3cd7 --- /dev/null +++ b/internal/utils/fifo/constants.go @@ -0,0 +1,5 @@ +package fifo + +const ( + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" +) diff --git a/internal/utils/fifo/registry.go b/internal/utils/fifo/registry.go new file mode 100644 index 00000000..61fed294 --- /dev/null +++ b/internal/utils/fifo/registry.go @@ -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 +} diff --git a/test/integration/production/recordings/recording_fifo_integration_test.go b/test/integration/production/recordings/recording_fifo_integration_test.go new file mode 100644 index 00000000..a845e1a2 --- /dev/null +++ b/test/integration/production/recordings/recording_fifo_integration_test.go @@ -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 +}