Files
lti-api/cmd/delete-adjustments/main.go

408 lines
12 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type adjustmentRow struct {
ID uint `gorm:"column:id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
FunctionCode string `gorm:"column:function_code"`
TotalQty float64 `gorm:"column:total_qty"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type routeResolution struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
}
func main() {
var (
idsRaw string
apply bool
)
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
flag.BoolVar(&apply, "apply", false, "Apply delete. If false, run as dry-run")
flag.Parse()
ids, err := parseIDs(idsRaw)
if err != nil {
log.Fatalf("invalid --ids: %v", err)
}
if len(ids) == 0 {
log.Fatal("--ids is required")
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
adjustments, err := loadAdjustments(ctx, db, ids)
if err != nil {
log.Fatalf("failed to load adjustments: %v", err)
}
if len(adjustments) == 0 {
log.Fatal("no adjustments found for provided IDs")
}
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].ID < adjustments[j].ID
})
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
success := 0
failed := 0
skipped := 0
for _, adj := range adjustments {
if strings.TrimSpace(adj.FunctionCode) == "" {
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
skipped++
continue
}
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
if err != nil {
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
failed++
continue
}
switch route.Lane {
case "USABLE":
desiredQty := adj.UsageQty + adj.PendingQty
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
desiredQty = adj.StockLogDecrease
}
activeAlloc, err := countActiveUsableAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count usable allocations: %v\n", adj.ID, err)
failed++
continue
}
fmt.Printf(
"PLAN adj=%d lane=USABLE function=%s usage=%.3f pending=%.3f active_alloc=%d action=reflow_to_zero+delete\n",
adj.ID,
route.FunctionCode,
adj.UsageQty,
adj.PendingQty,
activeAlloc,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
AsOf: &adj.CreatedAt,
IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()),
Tx: tx,
}
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
return fmt.Errorf("reflow usable to zero: %w", err)
}
if err := hardDeleteUsableAllocations(ctx, tx, fifo.UsableKeyAdjustmentOut.String(), adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
return err
}
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
success++
case "STOCKABLE":
removeQty := adj.TotalQty
if removeQty <= 0 && adj.StockLogIncrease > 0 {
removeQty = adj.StockLogIncrease
}
activeAlloc, err := countActiveStockableAllocations(ctx, db, fifo.StockableKeyAdjustmentIn.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count stockable allocations: %v\n", adj.ID, err)
failed++
continue
}
if activeAlloc > 0 {
fmt.Printf(
"FAIL adj=%d reason=stockable still allocated active_alloc=%d action=delete blocked\n",
adj.ID,
activeAlloc,
)
failed++
continue
}
fmt.Printf(
"PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reflow_to_zero+delete\n",
adj.ID,
route.FunctionCode,
adj.TotalQty,
removeQty,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).
Table("adjustment_stocks").
Where("id = ?", adj.ID).
Updates(map[string]any{
"total_qty": 0,
"total_used": 0,
}).Error; err != nil {
return fmt.Errorf("set stockable qty to zero: %w", err)
}
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
AsOf: &adj.CreatedAt,
IdempotencyKey: fmt.Sprintf("delete-adjustment-stockable-%d-%d", adj.ID, time.Now().UnixNano()),
Tx: tx,
}
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
return fmt.Errorf("reflow stockable to zero: %w", err)
}
if err := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
return err
}
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
success++
default:
fmt.Printf("SKIP adj=%d reason=unsupported lane=%s\n", adj.ID, route.Lane)
skipped++
}
}
fmt.Println()
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
if failed > 0 {
os.Exit(1)
}
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func parseIDs(raw string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
out := make([]uint, 0, len(parts))
seen := map[uint]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
v, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid id %q", part)
}
if v == 0 {
return nil, fmt.Errorf("id must be > 0: %q", part)
}
id := uint(v)
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
var rows []adjustmentRow
err := db.WithContext(ctx).
Table("adjustment_stocks a").
Select(`
a.id,
a.product_warehouse_id,
pw.product_id,
a.function_code,
COALESCE(a.total_qty, 0) AS total_qty,
COALESCE(a.usage_qty, 0) AS usage_qty,
COALESCE(a.pending_qty, 0) AS pending_qty,
COALESCE((
SELECT sl.increase
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_increase,
COALESCE((
SELECT sl.decrease
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_decrease,
a.created_at
`).
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
Where("a.id IN ?", ids).
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
var rows []routeResolution
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code, rr.lane, rr.function_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.function_code = ?", functionCode).
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, entity.FlagableTypeProduct, productID).
Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
Order("rr.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
}
selected := rows[0]
for _, row := range rows {
if row.Lane != selected.Lane {
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
}
}
selected.FunctionCode = functionCode
return &selected, nil
}
func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
return count, err
}
func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockableType string, stockableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
return count, err
}
func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationPurposeConsume).
Error
}
func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ? AND allocation_purpose = ?", stockableType, stockableID, entity.StockAllocationPurposeConsume).
Error
}
func hardDeleteAdjustmentStockLogs(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_logs WHERE loggable_type = ? AND loggable_id = ?", "ADJUSTMENT", adjustmentID).
Error
}
func hardDeleteAdjustment(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM adjustment_stocks WHERE id = ?", adjustmentID).
Error
}