mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
749 lines
21 KiB
Go
749 lines
21 KiB
Go
package fifo_stock_v2
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type allocationRow struct {
|
|
ID uint `gorm:"column:id"`
|
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
|
StockableType string `gorm:"column:stockable_type"`
|
|
StockableID uint `gorm:"column:stockable_id"`
|
|
UsableType string `gorm:"column:usable_type"`
|
|
UsableID uint `gorm:"column:usable_id"`
|
|
Qty float64 `gorm:"column:qty"`
|
|
Status string `gorm:"column:status"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
}
|
|
|
|
type usableQtySnapshot struct {
|
|
Usage float64 `gorm:"column:usage_qty"`
|
|
Pending float64 `gorm:"column:pending_qty"`
|
|
}
|
|
|
|
func (s *fifoStockV2Service) Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error) {
|
|
if err := s.validateAllocateRequest(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &AllocateResult{}
|
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
|
if err := s.ensureStockAllocationColumns(tx); err != nil {
|
|
return err
|
|
}
|
|
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
|
|
return err
|
|
}
|
|
|
|
hash := requestHash(map[string]any{
|
|
"flag_group_code": req.FlagGroupCode,
|
|
"product_warehouse_id": req.ProductWarehouseID,
|
|
"usable_type": req.Usable.LegacyTypeKey,
|
|
"usable_id": req.Usable.ID,
|
|
"need_qty": req.NeedQty,
|
|
"as_of": req.AsOf,
|
|
"allow_over_consume": req.AllowOverConsume,
|
|
})
|
|
logRow, reused, err := s.beginOperation(
|
|
tx,
|
|
OperationAllocate,
|
|
req.IdempotencyKey,
|
|
hash,
|
|
req.ProductWarehouseID,
|
|
req.FlagGroupCode,
|
|
req.Usable.LegacyTypeKey,
|
|
req.Usable.ID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if reused {
|
|
if len(logRow.ResultPayload) == 0 {
|
|
return fmt.Errorf("idempotent allocate has empty payload")
|
|
}
|
|
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if logRow != nil {
|
|
defer func() {
|
|
if err != nil {
|
|
s.failOperation(tx, logRow, err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
allocated, allocErr := s.allocateInternal(ctx, tx, req)
|
|
if allocErr != nil {
|
|
err = allocErr
|
|
return allocErr
|
|
}
|
|
*result = *allocated
|
|
|
|
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
|
|
err = finishErr
|
|
return finishErr
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, req AllocateRequest) (*AllocateResult, error) {
|
|
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, req.FlagGroupCode, req.Usable.LegacyTypeKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
allowOverConsume := usableRule.AllowPendingDefault
|
|
if req.AllowOverConsume != nil {
|
|
allowOverConsume = *req.AllowOverConsume
|
|
} else {
|
|
allowOverConsume, err = s.resolveOverConsume(tx, req.FlagGroupCode, req.Usable.FunctionCode, LaneUsable, allowOverConsume)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
gatherRows, err := s.gatherRows(ctx, tx, GatherRequest{
|
|
FlagGroupCode: req.FlagGroupCode,
|
|
Lane: LaneStockable,
|
|
ProductWarehouseID: req.ProductWarehouseID,
|
|
AsOf: req.AsOf,
|
|
Limit: s.defaultGatherLimit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, req.FlagGroupCode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
remaining := req.NeedQty
|
|
result := &AllocateResult{Details: make([]AllocationDetail, 0)}
|
|
|
|
for _, lot := range gatherRows {
|
|
if remaining <= 0 {
|
|
break
|
|
}
|
|
if lot.AvailableQuantity <= 0 {
|
|
continue
|
|
}
|
|
portion := math.Min(remaining, lot.AvailableQuantity)
|
|
if nearlyZero(portion) {
|
|
continue
|
|
}
|
|
|
|
allocationInsert := map[string]any{
|
|
"product_warehouse_id": req.ProductWarehouseID,
|
|
"stockable_type": lot.Ref.LegacyTypeKey,
|
|
"stockable_id": lot.Ref.ID,
|
|
"usable_type": req.Usable.LegacyTypeKey,
|
|
"usable_id": req.Usable.ID,
|
|
"qty": portion,
|
|
"status": activeAllocationStatus(),
|
|
"allocation_purpose": defaultAllocationPurpose(),
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"engine_version": "v2",
|
|
"flag_group_code": req.FlagGroupCode,
|
|
"function_code": req.Usable.FunctionCode,
|
|
}
|
|
if strings.TrimSpace(req.IdempotencyKey) != "" {
|
|
allocationInsert["idempotency_key"] = req.IdempotencyKey
|
|
}
|
|
if err := tx.Table("stock_allocations").Create(allocationInsert).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rule, ok := stockableRuleMap[lot.Ref.LegacyTypeKey]
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing stockable route rule for type %s", lot.Ref.LegacyTypeKey)
|
|
}
|
|
if err := s.adjustStockableUsedQuantity(tx, rule, lot.Ref.ID, portion); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result.Details = append(result.Details, AllocationDetail{
|
|
StockableType: lot.Ref.LegacyTypeKey,
|
|
StockableID: lot.Ref.ID,
|
|
Qty: portion,
|
|
SortAt: lot.SortAt,
|
|
})
|
|
|
|
remaining -= portion
|
|
result.AllocatedQty += portion
|
|
}
|
|
|
|
if remaining > 0 {
|
|
if !allowOverConsume {
|
|
return nil, fmt.Errorf("%w: requested %.3f, allocated %.3f", ErrInsufficientStock, req.NeedQty, result.AllocatedQty)
|
|
}
|
|
result.PendingQty = remaining
|
|
}
|
|
|
|
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, result.AllocatedQty, result.PendingQty); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, -result.AllocatedQty); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) {
|
|
if err := s.validateRollbackRequest(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &RollbackResult{}
|
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
|
if err := s.ensureStockAllocationColumns(tx); err != nil {
|
|
return err
|
|
}
|
|
|
|
flagGroupCode, err := s.resolveRollbackFlagGroup(ctx, tx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.lockShard(tx, flagGroupCode, req.ProductWarehouseID); err != nil {
|
|
return err
|
|
}
|
|
|
|
hash := requestHash(map[string]any{
|
|
"product_warehouse_id": req.ProductWarehouseID,
|
|
"usable_type": req.Usable.LegacyTypeKey,
|
|
"usable_id": req.Usable.ID,
|
|
"release_qty": req.ReleaseQty,
|
|
"reason": req.Reason,
|
|
"flag_group_code": flagGroupCode,
|
|
})
|
|
logRow, reused, beginErr := s.beginOperation(
|
|
tx,
|
|
OperationRollback,
|
|
req.IdempotencyKey,
|
|
hash,
|
|
req.ProductWarehouseID,
|
|
flagGroupCode,
|
|
req.Usable.LegacyTypeKey,
|
|
req.Usable.ID,
|
|
)
|
|
if beginErr != nil {
|
|
return beginErr
|
|
}
|
|
if reused {
|
|
if len(logRow.ResultPayload) == 0 {
|
|
return fmt.Errorf("idempotent rollback has empty payload")
|
|
}
|
|
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if logRow != nil {
|
|
defer func() {
|
|
if err != nil {
|
|
s.failOperation(tx, logRow, err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
rolled, rollbackErr := s.rollbackInternal(ctx, tx, req, flagGroupCode)
|
|
if rollbackErr != nil {
|
|
err = rollbackErr
|
|
return rollbackErr
|
|
}
|
|
*result = *rolled
|
|
|
|
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
|
|
err = finishErr
|
|
return finishErr
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *fifoStockV2Service) rollbackInternal(
|
|
ctx context.Context,
|
|
tx *gorm.DB,
|
|
req RollbackRequest,
|
|
flagGroupCode string,
|
|
) (*RollbackResult, error) {
|
|
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, flagGroupCode, req.Usable.LegacyTypeKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
allocations, err := s.loadActiveAllocations(tx, req.Usable.LegacyTypeKey, req.Usable.ID, req.ProductWarehouseID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(allocations) == 0 {
|
|
if req.ReleaseQty == nil {
|
|
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &RollbackResult{}, nil
|
|
}
|
|
|
|
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, flagGroupCode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
target := 0.0
|
|
for _, alloc := range allocations {
|
|
target += alloc.Qty
|
|
}
|
|
if req.ReleaseQty != nil {
|
|
if *req.ReleaseQty < 0 {
|
|
return nil, fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
|
|
}
|
|
target = *req.ReleaseQty
|
|
}
|
|
if nearlyZero(target) {
|
|
return &RollbackResult{}, nil
|
|
}
|
|
|
|
result := &RollbackResult{Details: make([]AllocationDetail, 0)}
|
|
now := time.Now()
|
|
remaining := target
|
|
|
|
for _, alloc := range allocations {
|
|
if remaining <= 0 {
|
|
break
|
|
}
|
|
portion := math.Min(remaining, alloc.Qty)
|
|
if nearlyZero(portion) {
|
|
continue
|
|
}
|
|
|
|
if nearlyZero(alloc.Qty - portion) {
|
|
updates := map[string]any{
|
|
"status": releasedAllocationStatus(),
|
|
"released_at": now,
|
|
"updated_at": now,
|
|
}
|
|
if strings.TrimSpace(req.Reason) != "" {
|
|
updates["note"] = req.Reason
|
|
}
|
|
if err := tx.Table("stock_allocations").Where("id = ?", alloc.ID).Updates(updates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if err := tx.Table("stock_allocations").
|
|
Where("id = ?", alloc.ID).
|
|
Updates(map[string]any{
|
|
"qty": alloc.Qty - portion,
|
|
"updated_at": now,
|
|
}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
stockableRule, ok := stockableRuleMap[alloc.StockableType]
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing stockable route rule for type %s", alloc.StockableType)
|
|
}
|
|
if err := s.adjustStockableUsedQuantity(tx, stockableRule, alloc.StockableID, -portion); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result.ReleasedQty += portion
|
|
remaining -= portion
|
|
result.Details = append(result.Details, AllocationDetail{
|
|
StockableType: alloc.StockableType,
|
|
StockableID: alloc.StockableID,
|
|
Qty: portion,
|
|
SortAt: alloc.CreatedAt,
|
|
})
|
|
}
|
|
|
|
if req.ReleaseQty != nil && remaining > 1e-6 {
|
|
return nil, fmt.Errorf("unable to release %.3f; only %.3f allocation exists", target, result.ReleasedQty)
|
|
}
|
|
|
|
if req.ReleaseQty == nil {
|
|
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, -result.ReleasedQty, 0); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, result.ReleasedQty); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error) {
|
|
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
|
|
return nil, fmt.Errorf("%w: invalid reflow request", ErrInvalidRequest)
|
|
}
|
|
|
|
result := &ReflowResult{}
|
|
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
|
if err := s.ensureStockAllocationColumns(tx); err != nil {
|
|
return err
|
|
}
|
|
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
|
|
return err
|
|
}
|
|
|
|
hash := requestHash(map[string]any{
|
|
"flag_group_code": req.FlagGroupCode,
|
|
"product_warehouse_id": req.ProductWarehouseID,
|
|
"as_of": req.AsOf,
|
|
})
|
|
logRow, reused, err := s.beginOperation(
|
|
tx,
|
|
OperationReflow,
|
|
req.IdempotencyKey,
|
|
hash,
|
|
req.ProductWarehouseID,
|
|
req.FlagGroupCode,
|
|
"",
|
|
0,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if reused {
|
|
if len(logRow.ResultPayload) == 0 {
|
|
return fmt.Errorf("idempotent reflow has empty payload")
|
|
}
|
|
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if logRow != nil {
|
|
defer func() {
|
|
if err != nil {
|
|
s.failOperation(tx, logRow, err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
usableRows, gatherErr := s.gatherAllRows(ctx, tx, GatherRequest{
|
|
FlagGroupCode: req.FlagGroupCode,
|
|
Lane: LaneUsable,
|
|
ProductWarehouseID: req.ProductWarehouseID,
|
|
Limit: s.defaultGatherLimit,
|
|
})
|
|
if gatherErr != nil {
|
|
err = gatherErr
|
|
return gatherErr
|
|
}
|
|
result.ProcessedUsables = len(usableRows)
|
|
|
|
for _, usableRow := range usableRows {
|
|
desiredQty := usableRow.Quantity + usableRow.PendingQuantity
|
|
|
|
rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{
|
|
ProductWarehouseID: req.ProductWarehouseID,
|
|
Usable: usableRow.Ref,
|
|
ReleaseQty: nil,
|
|
Reason: "reflow reset",
|
|
}, req.FlagGroupCode)
|
|
if rollbackErr != nil {
|
|
err = rollbackErr
|
|
return rollbackErr
|
|
}
|
|
result.Rollback.ReleasedQty += rollbackRes.ReleasedQty
|
|
if len(rollbackRes.Details) > 0 {
|
|
result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...)
|
|
}
|
|
minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity
|
|
if desiredQty < minDesired {
|
|
desiredQty = minDesired
|
|
}
|
|
|
|
if desiredQty <= 0 {
|
|
continue
|
|
}
|
|
allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{
|
|
FlagGroupCode: req.FlagGroupCode,
|
|
ProductWarehouseID: req.ProductWarehouseID,
|
|
Usable: usableRow.Ref,
|
|
NeedQty: desiredQty,
|
|
AsOf: nil,
|
|
})
|
|
if allocateErr != nil {
|
|
err = allocateErr
|
|
return allocateErr
|
|
}
|
|
result.Allocate.AllocatedQty += allocateRes.AllocatedQty
|
|
result.Allocate.PendingQty += allocateRes.PendingQty
|
|
if len(allocateRes.Details) > 0 {
|
|
result.Allocate.Details = append(result.Allocate.Details, allocateRes.Details...)
|
|
}
|
|
}
|
|
|
|
expectedQty, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, req.ProductWarehouseID, req.FlagGroupCode, nil)
|
|
if calcErr != nil {
|
|
err = calcErr
|
|
return calcErr
|
|
}
|
|
actualQty, loadErr := s.loadWarehouseQty(ctx, tx, req.ProductWarehouseID)
|
|
if loadErr != nil {
|
|
err = loadErr
|
|
return loadErr
|
|
}
|
|
drift := expectedQty - actualQty
|
|
if math.Abs(drift) >= 1e-6 {
|
|
if adjustErr := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, drift); adjustErr != nil {
|
|
err = adjustErr
|
|
return adjustErr
|
|
}
|
|
}
|
|
|
|
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
|
|
err = finishErr
|
|
return finishErr
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *fifoStockV2Service) gatherAllRows(
|
|
ctx context.Context,
|
|
tx *gorm.DB,
|
|
req GatherRequest,
|
|
) ([]GatherRow, error) {
|
|
limit := req.Limit
|
|
if limit <= 0 {
|
|
limit = s.defaultGatherLimit
|
|
}
|
|
if limit <= 0 {
|
|
limit = 1000
|
|
}
|
|
|
|
req.Limit = limit
|
|
out := make([]GatherRow, 0, limit)
|
|
|
|
var cursorSortAt *time.Time
|
|
cursorSourceTable := ""
|
|
var cursorSourceID uint
|
|
|
|
for {
|
|
req.AfterSortAt = cursorSortAt
|
|
req.AfterSourceTable = cursorSourceTable
|
|
req.AfterSourceID = cursorSourceID
|
|
|
|
rows, err := s.gatherRows(ctx, tx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
break
|
|
}
|
|
|
|
out = append(out, rows...)
|
|
if len(rows) < limit {
|
|
break
|
|
}
|
|
|
|
last := rows[len(rows)-1]
|
|
lastSortAt := last.SortAt
|
|
cursorSortAt = &lastSortAt
|
|
cursorSourceTable = last.SourceTable
|
|
cursorSourceID = last.SourceID
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (s *fifoStockV2Service) loadActiveAllocations(
|
|
tx *gorm.DB,
|
|
usableType string,
|
|
usableID uint,
|
|
productWarehouseID uint,
|
|
) ([]allocationRow, error) {
|
|
query := tx.Table("stock_allocations").
|
|
Select("id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, created_at").
|
|
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, activeAllocationStatus(), defaultAllocationPurpose())
|
|
if productWarehouseID > 0 {
|
|
query = query.Where("product_warehouse_id = ?", productWarehouseID)
|
|
}
|
|
query = query.Order("created_at DESC, id DESC")
|
|
|
|
var rows []allocationRow
|
|
if err := query.Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *fifoStockV2Service) loadStockableRuleMap(ctx context.Context, tx *gorm.DB, flagGroupCode string) (map[string]routeRule, error) {
|
|
rules, err := s.loadRouteRules(ctx, tx, flagGroupCode, LaneStockable)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m := make(map[string]routeRule, len(rules))
|
|
for _, rule := range rules {
|
|
m[rule.LegacyTypeKey] = rule
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (s *fifoStockV2Service) adjustStockableUsedQuantity(tx *gorm.DB, rule routeRule, sourceID uint, delta float64) error {
|
|
if nearlyZero(delta) || sourceID == 0 {
|
|
return nil
|
|
}
|
|
if rule.UsedQuantityCol == nil || strings.TrimSpace(*rule.UsedQuantityCol) == "" {
|
|
return nil
|
|
}
|
|
|
|
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
|
|
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
|
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
|
|
|
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usedCol)
|
|
return tx.Table(sourceTable).
|
|
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
|
|
Update(usedCol, gorm.Expr(expr, delta)).Error
|
|
}
|
|
|
|
func (s *fifoStockV2Service) applyUsableDeltas(tx *gorm.DB, rule routeRule, sourceID uint, usageDelta, pendingDelta float64) error {
|
|
if sourceID == 0 || (nearlyZero(usageDelta) && nearlyZero(pendingDelta)) {
|
|
return nil
|
|
}
|
|
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
|
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
|
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
|
|
|
|
updates := map[string]any{}
|
|
if !nearlyZero(usageDelta) {
|
|
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usageCol)
|
|
updates[usageCol] = gorm.Expr(expr, usageDelta)
|
|
}
|
|
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" && !nearlyZero(pendingDelta) {
|
|
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
|
|
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", pendingCol)
|
|
updates[pendingCol] = gorm.Expr(expr, pendingDelta)
|
|
}
|
|
if len(updates) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return tx.Table(sourceTable).
|
|
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
|
|
Updates(updates).Error
|
|
}
|
|
|
|
func (s *fifoStockV2Service) resetUsableQuantities(tx *gorm.DB, rule routeRule, sourceID uint) error {
|
|
if sourceID == 0 {
|
|
return nil
|
|
}
|
|
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
|
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
|
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
|
|
|
|
updates := map[string]any{usageCol: 0}
|
|
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
|
|
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
|
|
updates[pendingCol] = 0
|
|
}
|
|
|
|
return tx.Table(sourceTable).
|
|
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
|
|
Updates(updates).Error
|
|
}
|
|
|
|
func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *gorm.DB, req RollbackRequest) (string, error) {
|
|
type row struct {
|
|
FlagGroupCode string `gorm:"column:flag_group_code"`
|
|
}
|
|
var latest row
|
|
err := tx.WithContext(ctx).
|
|
Table("stock_allocations").
|
|
Select("flag_group_code").
|
|
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
|
|
Where("engine_version = 'v2'").
|
|
Where("allocation_purpose = ?", defaultAllocationPurpose()).
|
|
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''").
|
|
Order("id DESC").
|
|
Limit(1).
|
|
Take(&latest).Error
|
|
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
|
|
return latest.FlagGroupCode, nil
|
|
}
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return "", err
|
|
}
|
|
|
|
var rules []routeRule
|
|
err = tx.WithContext(ctx).
|
|
Table("fifo_stock_v2_route_rules").
|
|
Where("is_active = TRUE").
|
|
Where("lane = ?", string(LaneUsable)).
|
|
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey).
|
|
Find(&rules).Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(rules) == 0 {
|
|
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey)
|
|
}
|
|
if len(rules) > 1 {
|
|
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
|
|
}
|
|
return rules[0].FlagGroupCode, nil
|
|
}
|
|
|
|
func (s *fifoStockV2Service) validateAllocateRequest(req AllocateRequest) error {
|
|
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
|
|
return fmt.Errorf("%w: missing flag group or product warehouse", ErrInvalidRequest)
|
|
}
|
|
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
|
|
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
|
|
}
|
|
if req.NeedQty < 0 {
|
|
return fmt.Errorf("%w: need qty must be >= 0", ErrInvalidRequest)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *fifoStockV2Service) validateRollbackRequest(req RollbackRequest) error {
|
|
if req.ProductWarehouseID == 0 {
|
|
return fmt.Errorf("%w: product warehouse is required", ErrInvalidRequest)
|
|
}
|
|
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
|
|
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
|
|
}
|
|
if req.ReleaseQty != nil && *req.ReleaseQty < 0 {
|
|
return fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
|
|
}
|
|
return nil
|
|
}
|