add paired adjustment triger depletion adjustment

This commit is contained in:
ragilap
2026-03-17 11:02:37 +07:00
parent 131949874a
commit c9dee7d1c4
9 changed files with 839 additions and 381 deletions
@@ -701,16 +701,17 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var latest row
err := tx.WithContext(ctx).
latestQuery := 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
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''")
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
latestQuery = latestQuery.Where("function_code = ?", code)
}
err := latestQuery.Order("id DESC").Limit(1).Take(&latest).Error
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
return latest.FlagGroupCode, nil
}
@@ -718,19 +719,56 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
return "", err
}
var rules []routeRule
err = tx.WithContext(ctx).
rulesQuery := 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
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey)
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
rulesQuery = rulesQuery.Where("function_code = ?", code)
}
var rules []routeRule
err = rulesQuery.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 && req.ProductWarehouseID != 0 {
type candidateRow struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var candidates []candidateRow
byProductQuery := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("DISTINCT rr.flag_group_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.lane = ?", string(LaneUsable)).
Where("rr.legacy_type_key = ?", req.Usable.LegacyTypeKey).
Where(`
EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = ?
AND f.flagable_type = 'products'
AND fm.flag_group_code = rr.flag_group_code
)
`, req.ProductWarehouseID)
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
byProductQuery = byProductQuery.Where("rr.function_code = ?", code)
}
if err := byProductQuery.Order("rr.flag_group_code ASC").Scan(&candidates).Error; err != nil {
return "", err
}
if len(candidates) == 1 {
return strings.TrimSpace(candidates[0].FlagGroupCode), nil
}
}
if len(rules) > 1 {
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
}
@@ -0,0 +1,14 @@
BEGIN;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS chk_adjustment_stocks_paired_not_self;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_paired_adjustment_id;
DROP INDEX IF EXISTS idx_adjustment_stocks_paired_adjustment_id;
ALTER TABLE adjustment_stocks
DROP COLUMN IF EXISTS paired_adjustment_id;
COMMIT;
@@ -0,0 +1,86 @@
BEGIN;
ALTER TABLE adjustment_stocks
ADD COLUMN IF NOT EXISTS paired_adjustment_id BIGINT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_adjustment_stocks_paired_adjustment_id'
) THEN
ALTER TABLE adjustment_stocks
ADD CONSTRAINT fk_adjustment_stocks_paired_adjustment_id
FOREIGN KEY (paired_adjustment_id)
REFERENCES adjustment_stocks(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS chk_adjustment_stocks_paired_not_self;
ALTER TABLE adjustment_stocks
ADD CONSTRAINT chk_adjustment_stocks_paired_not_self
CHECK (paired_adjustment_id IS NULL OR paired_adjustment_id <> id);
CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_paired_adjustment_id
ON adjustment_stocks(paired_adjustment_id);
-- Backfill pairing untuk depletion-out <-> depletion-in existing records.
WITH candidates AS (
SELECT
src.id AS src_id,
dst.id AS dst_id,
ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) AS ts_diff,
ABS(dst.id - src.id) AS id_diff,
ROW_NUMBER() OVER (
PARTITION BY src.id
ORDER BY ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) ASC,
ABS(dst.id - src.id) ASC,
dst.id ASC
) AS rn_src,
ROW_NUMBER() OVER (
PARTITION BY dst.id
ORDER BY ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) ASC,
ABS(dst.id - src.id) ASC,
src.id ASC
) AS rn_dst
FROM adjustment_stocks src
JOIN adjustment_stocks dst
ON dst.id <> src.id
AND dst.transaction_type = src.transaction_type
AND dst.function_code = 'RECORDING_DEPLETION_IN'
AND src.function_code = 'RECORDING_DEPLETION_OUT'
AND dst.paired_adjustment_id IS NULL
AND src.paired_adjustment_id IS NULL
AND ABS((COALESCE(src.usage_qty, 0) + COALESCE(src.pending_qty, 0)) - COALESCE(dst.total_qty, 0)) < 0.0001
AND COALESCE(src.price, 0) = COALESCE(dst.price, 0)
AND COALESCE(src.grand_total, 0) = COALESCE(dst.grand_total, 0)
AND ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) <= 120
),
chosen AS (
SELECT src_id, dst_id
FROM candidates
WHERE rn_src = 1
AND rn_dst = 1
)
UPDATE adjustment_stocks src
SET paired_adjustment_id = c.dst_id
FROM chosen c
WHERE src.id = c.src_id
AND src.paired_adjustment_id IS NULL;
WITH chosen AS (
SELECT a.id AS src_id, a.paired_adjustment_id AS dst_id
FROM adjustment_stocks a
WHERE a.function_code = 'RECORDING_DEPLETION_OUT'
AND a.paired_adjustment_id IS NOT NULL
)
UPDATE adjustment_stocks dst
SET paired_adjustment_id = c.src_id
FROM chosen c
WHERE dst.id = c.dst_id
AND dst.paired_adjustment_id IS NULL;
COMMIT;
+2
View File
@@ -5,6 +5,7 @@ import "time"
type AdjustmentStock struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
PairedAdjustmentId *uint `gorm:"column:paired_adjustment_id"`
TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"`
FunctionCode string `gorm:"column:function_code;type:varchar(64)"`
TotalQty float64 `gorm:"column:total_qty;default:0"`
@@ -18,5 +19,6 @@ type AdjustmentStock struct {
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
PairedAdjustment *AdjustmentStock `gorm:"foreignKey:PairedAdjustmentId;references:Id"`
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
}
@@ -2,12 +2,12 @@ package repositories
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -15,9 +15,19 @@ import (
type AdjustmentStockRepository interface {
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error)
GetByIDForUpdate(ctx context.Context, id uint) (*entity.AdjustmentStock, error)
FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error)
FindProductIDByProductWarehouseID(ctx context.Context, productWarehouseID uint) (uint, error)
FindRoutesByFunctionCode(ctx context.Context, productID uint, functionCode string) ([]AdjustmentRouteResolution, error)
FindOverconsumeRule(ctx context.Context, lane, flagGroupCode, functionCode string) (*bool, error)
LoadDownstreamDependencies(ctx context.Context, stockableType string, stockableIDs []uint) ([]AdjustmentDownstreamDependency, error)
FindAyamSourceProductWarehouse(ctx context.Context, warehouseID uint, projectFlockKandangID uint) (*entity.ProductWarehouse, error)
IsAyamProduct(ctx context.Context, productID uint) (bool, error)
CountActiveConsumeAllocationsByUsable(ctx context.Context, usableType string, usableID uint) (int64, error)
UpdateTotalQty(ctx context.Context, id uint, qty float64) error
UpdatePairedAdjustmentID(ctx context.Context, id uint, pairedID uint) error
DeleteStockLogsByAdjustmentID(ctx context.Context, adjustmentID uint) error
DeleteAdjustmentByID(ctx context.Context, id uint) error
ResyncProjectFlockPopulationUsage(ctx context.Context, projectFlockKandangID uint) error
FindHistory(ctx context.Context, filter AdjustmentHistoryFilter, modifier func(*gorm.DB) *gorm.DB) ([]*entity.AdjustmentStock, int64, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB
@@ -44,6 +54,13 @@ type AdjustmentHistoryFilter struct {
Limit int
}
type AdjustmentDownstreamDependency struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint64 `gorm:"column:usable_id"`
FunctionCode string `gorm:"column:function_code"`
FlagGroupCode string `gorm:"column:flag_group_code"`
}
type adjustmentStockRepositoryImpl struct {
db *gorm.DB
}
@@ -73,6 +90,17 @@ func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, mo
return &record, nil
}
func (r *adjustmentStockRepositoryImpl) GetByIDForUpdate(ctx context.Context, id uint) (*entity.AdjustmentStock, error) {
var record entity.AdjustmentStock
if err := r.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", id).
Take(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
type pfkRow struct {
KandangID uint `gorm:"column:kandang_id"`
@@ -91,6 +119,21 @@ func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx
return pfk.KandangID, nil
}
func (r *adjustmentStockRepositoryImpl) FindProductIDByProductWarehouseID(ctx context.Context, productWarehouseID uint) (uint, error) {
type productRow struct {
ProductID uint `gorm:"column:product_id"`
}
var row productRow
if err := r.db.WithContext(ctx).
Table("product_warehouses").
Select("product_id").
Where("id = ?", productWarehouseID).
Take(&row).Error; err != nil {
return 0, err
}
return row.ProductID, nil
}
func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
ctx context.Context,
productID uint,
@@ -122,37 +165,183 @@ func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
return rows, nil
}
func (r *adjustmentStockRepositoryImpl) FindOverconsumeRule(
func (r *adjustmentStockRepositoryImpl) LoadDownstreamDependencies(
ctx context.Context,
lane string,
flagGroupCode string,
functionCode string,
) (*bool, error) {
type selectedRow struct {
AllowOverconsume bool `gorm:"column:allow_overconsume"`
}
var selected selectedRow
err := r.db.WithContext(ctx).
Table("fifo_stock_v2_overconsume_rules").
Select("allow_overconsume").
Where("is_active = TRUE").
Where("lane = ?", lane).
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
Where("(function_code IS NULL OR function_code = ?)", functionCode).
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
Order("priority ASC, id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
stockableType string,
stockableIDs []uint,
) ([]AdjustmentDownstreamDependency, error) {
if strings.TrimSpace(stockableType) == "" || len(stockableIDs) == 0 {
return nil, nil
}
var rows []AdjustmentDownstreamDependency
err := r.db.WithContext(ctx).
Table("stock_allocations").
Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
Where("stockable_type = ?", strings.ToUpper(strings.TrimSpace(stockableType))).
Where("stockable_id IN ?", stockableIDs).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Where(
"(usable_type <> ? OR EXISTS (SELECT 1 FROM project_chickins pc WHERE pc.id = stock_allocations.usable_id AND pc.deleted_at IS NULL))",
"PROJECT_CHICKIN",
).
Group("usable_type, usable_id, function_code, flag_group_code").
Scan(&rows).Error
if err != nil {
return nil, err
}
return &selected.AllowOverconsume, nil
return rows, nil
}
func (r *adjustmentStockRepositoryImpl) FindAyamSourceProductWarehouse(
ctx context.Context,
warehouseID uint,
projectFlockKandangID uint,
) (*entity.ProductWarehouse, error) {
var sourcePW entity.ProductWarehouse
err := r.db.WithContext(ctx).
Model(&entity.ProductWarehouse{}).
Where("project_flock_kandang_id = ?", projectFlockKandangID).
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 = product_warehouses.product_id
AND fm.flag_group_code = ?
)
`, entity.FlagableTypeProduct, "AYAM").
Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)).
Order("id ASC").
Take(&sourcePW).Error
if err != nil {
return nil, err
}
return &sourcePW, nil
}
func (r *adjustmentStockRepositoryImpl) IsAyamProduct(ctx context.Context, productID uint) (bool, error) {
if productID == 0 {
return false, nil
}
var count int64
if err := r.db.WithContext(ctx).
Table("flags f").
Joins("JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.flag_group_code = ? AND fm.is_active = TRUE", "AYAM").
Where("f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.flagable_id = ?", productID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *adjustmentStockRepositoryImpl) CountActiveConsumeAllocationsByUsable(
ctx context.Context,
usableType string,
usableID uint,
) (int64, error) {
if strings.TrimSpace(usableType) == "" || usableID == 0 {
return 0, nil
}
var count int64
err := r.db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ?", strings.ToUpper(strings.TrimSpace(usableType))).
Where("usable_id = ?", usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
func (r *adjustmentStockRepositoryImpl) UpdateTotalQty(ctx context.Context, id uint, qty float64) error {
return r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where("id = ?", id).
Update("total_qty", qty).Error
}
func (r *adjustmentStockRepositoryImpl) UpdatePairedAdjustmentID(ctx context.Context, id uint, pairedID uint) error {
return r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where("id = ?", id).
Update("paired_adjustment_id", pairedID).Error
}
func (r *adjustmentStockRepositoryImpl) DeleteStockLogsByAdjustmentID(ctx context.Context, adjustmentID uint) error {
return r.db.WithContext(ctx).
Where("loggable_type = ? AND loggable_id = ?", string(utils.StockLogTypeAdjustment), adjustmentID).
Delete(&entity.StockLog{}).Error
}
func (r *adjustmentStockRepositoryImpl) DeleteAdjustmentByID(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).
Where("id = ?", id).
Delete(&entity.AdjustmentStock{}).Error
}
func (r *adjustmentStockRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
idsSubquery := `
SELECT pfp.id
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
`
updateWithAlloc := `
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id
AND p.id IN (` + idsSubquery + `)
`
resetMissing := `
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE p.id IN (` + idsSubquery + `)
AND NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.stockable_id = p.id
)
`
db := r.db.WithContext(ctx)
if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil {
return err
}
if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil {
return err
}
return nil
}
func (r *adjustmentStockRepositoryImpl) FindHistory(
@@ -25,7 +25,6 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AdjustmentService interface {
@@ -51,16 +50,8 @@ type adjustmentService struct {
const (
adjustmentLaneStockable = "STOCKABLE"
adjustmentLaneUsable = "USABLE"
flagGroupAyam = "AYAM"
)
type adjustmentDownstreamDependency struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint64 `gorm:"column:usable_id"`
FunctionCode string `gorm:"column:function_code"`
FlagGroupCode string `gorm:"column:flag_group_code"`
}
func NewAdjustmentService(
productRepo productRepo.ProductRepository,
stockLogsRepo stockLogsRepo.StockLogRepository,
@@ -86,7 +77,12 @@ func NewAdjustmentService(
}
}
func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) {
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
return nil, err
}
adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
@@ -95,14 +91,7 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("StockLog.CreatedUser")
}
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) {
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
return nil, err
}
adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, s.withRelations)
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
@@ -132,41 +121,110 @@ func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error {
}
return s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var adjustment entity.AdjustmentStock
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", id).
Take(&adjustment).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load adjustment")
}
type productRow struct {
ProductID uint `gorm:"column:product_id"`
}
var prod productRow
if err := tx.WithContext(ctx).
Table("product_warehouses").
Select("product_id").
Where("id = ?", adjustment.ProductWarehouseId).
Take(&prod).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load product warehouse context")
}
routeMeta, err := s.resolveRouteByFunctionCode(ctx, prod.ProductID, adjustment.FunctionCode)
adjustments, err := s.collectAdjustmentsForDelete(ctx, tx, id)
if err != nil {
return err
}
isAyamProduct, err := s.isAyamProduct(ctx, tx, prod.ProductID)
for _, item := range adjustments {
if err := s.deleteSingleAdjustmentInTx(ctx, tx, item, actorID); err != nil {
return err
}
}
return nil
})
}
func (s *adjustmentService) collectAdjustmentsForDelete(ctx context.Context, tx *gorm.DB, id uint) ([]entity.AdjustmentStock, error) {
repoTx := s.AdjustmentStockRepository.WithTx(tx)
adjustment, err := repoTx.GetByIDForUpdate(ctx, id)
if err != nil {
s.Log.Errorf("Failed to resolve AYAM flag for product %d: %+v", prod.ProductID, err)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load adjustment")
}
adjustments := []entity.AdjustmentStock{*adjustment}
leftPairCode := utils.NormalizeUpper(adjustment.FunctionCode)
isDepletionCode := leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) ||
leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)
if !isDepletionCode {
return adjustments, nil
}
if adjustment.PairedAdjustmentId == nil || *adjustment.PairedAdjustmentId == 0 {
return nil, fiber.NewError(
fiber.StatusBadRequest,
"Adjustment depletion tidak memiliki pasangan valid. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.",
)
}
pair, err := repoTx.GetByIDForUpdate(ctx, *adjustment.PairedAdjustmentId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment depletion (%d) tidak ditemukan. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.", *adjustment.PairedAdjustmentId),
)
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load paired adjustment")
}
rightPairCode := utils.NormalizeUpper(pair.FunctionCode)
isPairDepletionCode := rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) ||
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)
if !isPairDepletionCode {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment %d bukan depletion pair yang valid", pair.Id),
)
}
if pair.PairedAdjustmentId == nil || *pair.PairedAdjustmentId != adjustment.Id {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment depletion tidak konsisten (%d <-> %d). Perbaiki pairing terlebih dahulu.", adjustment.Id, pair.Id),
)
}
isValidPair := (leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) &&
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)) ||
(leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) &&
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn))
if !isValidPair {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan function_code depletion tidak valid (%s <-> %s)", adjustment.FunctionCode, pair.FunctionCode),
)
}
adjustments = append(adjustments, *pair)
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].Id < adjustments[j].Id
})
return adjustments, nil
}
func (s *adjustmentService) deleteSingleAdjustmentInTx(
ctx context.Context,
tx *gorm.DB,
adjustment entity.AdjustmentStock,
actorID uint,
) error {
repoTx := s.AdjustmentStockRepository.WithTx(tx)
productID, err := repoTx.FindProductIDByProductWarehouseID(ctx, adjustment.ProductWarehouseId)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load product warehouse context")
}
routeMeta, err := s.resolveRouteByFunctionCode(ctx, productID, adjustment.FunctionCode)
if err != nil {
return err
}
isAyamProduct, err := repoTx.IsAyamProduct(ctx, productID)
if err != nil {
s.Log.Errorf("Failed to resolve AYAM flag for product %d: %+v", productID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product flag")
}
stockLogRepoTx := stockLogsRepo.NewStockLogRepository(tx)
notes := fmt.Sprintf("ADJUSTMENT DELETE#%s", strings.TrimSpace(adjustment.AdjNumber))
notes := fmt.Sprintf("ADJUSTMENT DELETE#%s", utils.NormalizeTrim(adjustment.AdjNumber))
switch routeMeta.Lane {
case adjustmentLaneStockable:
@@ -200,10 +258,7 @@ func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error {
oldQty := adjustment.TotalQty
if oldQty > 0 {
if err := tx.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where("id = ?", adjustment.Id).
Update("total_qty", 0).Error; err != nil {
if err := repoTx.UpdateTotalQty(ctx, adjustment.Id, 0); err != nil {
return err
}
asOf := adjustment.CreatedAt
@@ -229,12 +284,15 @@ func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error {
}
}
case adjustmentLaneUsable:
activeBeforeRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations before rollback")
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, common.FifoStockV2RollbackRequest{
ProductWarehouseID: adjustment.ProductWarehouseId,
Usable: common.FifoStockV2Ref{
ID: adjustment.Id,
LegacyTypeKey: routeMeta.LegacyTypeKey,
FunctionCode: routeMeta.FunctionCode,
LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(),
},
Reason: notes,
Tx: tx,
@@ -242,6 +300,21 @@ func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error {
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to rollback FIFO v2 adjustment: %v", err))
}
activeAfterRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations after rollback")
}
if activeAfterRollback > 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Adjustment tidak dapat dihapus karena masih ada alokasi aktif ADJUSTMENT_OUT=%d (sebelum rollback=%d, sesudah rollback=%d).",
adjustment.Id,
activeBeforeRollback,
activeAfterRollback,
),
)
}
releasedQty := 0.0
if rollbackRes != nil {
@@ -265,19 +338,13 @@ func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error {
return fiber.NewError(fiber.StatusBadRequest, "Unsupported adjustment lane")
}
if err := tx.WithContext(ctx).
Where("loggable_type = ? AND loggable_id = ?", string(utils.StockLogTypeAdjustment), adjustment.Id).
Delete(&entity.StockLog{}).Error; err != nil {
if err := repoTx.DeleteStockLogsByAdjustmentID(ctx, adjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment stock logs")
}
if err := tx.WithContext(ctx).
Where("id = ?", adjustment.Id).
Delete(&entity.AdjustmentStock{}).Error; err != nil {
if err := repoTx.DeleteAdjustmentByID(ctx, adjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment")
}
return nil
})
}
func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) {
@@ -298,12 +365,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero")
}
functionCode := strings.ToUpper(strings.TrimSpace(req.TransactionSubtype))
functionCode := utils.NormalizeUpper(req.TransactionSubtype)
if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(req.TransactionSubType))
functionCode = utils.NormalizeUpper(req.TransactionSubType)
}
if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(req.FunctionCode))
functionCode = utils.NormalizeUpper(req.FunctionCode)
}
if functionCode == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required")
@@ -320,9 +387,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, err
}
note := strings.TrimSpace(req.Notes)
note := utils.NormalizeTrim(req.Notes)
if note == "" {
note = strings.TrimSpace(req.Note)
note = utils.NormalizeTrim(req.Note)
}
grandTotal := math.Round((qty*req.Price)*1000) / 1000
@@ -404,8 +471,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
sourcePW, err := s.resolveAyamSourceProductWarehouse(ctx, tx, warehouseID, *projectFlockKandangID)
sourcePW, err := adjustmentStockRepoTX.FindAyamSourceProductWarehouse(ctx, warehouseID, *projectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan")
}
return err
}
if err := common.EnsureProjectFlockNotClosedForProductWarehouses(
@@ -461,6 +531,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err := adjustmentStockRepoTX.CreateOne(ctx, destinationAdjustment, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion destination adjustment stock record")
}
if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, sourceAdjustment.Id, destinationAdjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion source adjustment pair")
}
if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, destinationAdjustment.Id, sourceAdjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion destination adjustment pair")
}
sourceAdjustment.PairedAdjustmentId = &destinationAdjustment.Id
destinationAdjustment.PairedAdjustmentId = &sourceAdjustment.Id
sourceAsOf := sourceAdjustment.CreatedAt
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
@@ -502,7 +580,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
); err != nil {
return err
}
if err := s.resyncProjectFlockPopulationUsage(ctx, tx, *projectFlockKandangID); err != nil {
if err := adjustmentStockRepoTX.ResyncProjectFlockPopulationUsage(ctx, *projectFlockKandangID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to resync project flock population usage")
}
}
@@ -678,38 +756,13 @@ func (s *adjustmentService) resolveRouteByFunctionCode(
}
}
func (s *adjustmentService) resolveOverconsumePolicy(
ctx context.Context,
route *adjustmentStockRepo.AdjustmentRouteResolution,
) (bool, error) {
if route == nil {
return false, fmt.Errorf("route is required")
}
defaultValue := route.AllowPendingDefault
selected, err := s.AdjustmentStockRepository.FindOverconsumeRule(
ctx,
route.Lane,
route.FlagGroupCode,
route.FunctionCode,
)
if err != nil {
return false, err
}
if selected == nil {
return defaultValue, nil
}
return *selected, nil
}
func (s *adjustmentService) resolveAdjustmentDependenciesAndPolicy(
ctx context.Context,
tx *gorm.DB,
stockableType string,
stockableIDs []uint,
) ([]adjustmentDownstreamDependency, bool, error) {
deps, err := s.loadAdjustmentDownstreamDependencies(ctx, tx, stockableType, stockableIDs)
) ([]adjustmentStockRepo.AdjustmentDownstreamDependency, bool, error) {
deps, err := s.AdjustmentStockRepository.WithTx(tx).LoadDownstreamDependencies(ctx, stockableType, stockableIDs)
if err != nil {
s.Log.Errorf("Failed to load downstream adjustment dependencies: %+v", err)
return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate downstream adjustment dependencies")
@@ -739,50 +792,14 @@ func (s *adjustmentService) resolveAdjustmentDependenciesAndPolicy(
return deps, allowPending, nil
}
func (s *adjustmentService) loadAdjustmentDownstreamDependencies(
ctx context.Context,
tx *gorm.DB,
stockableType string,
stockableIDs []uint,
) ([]adjustmentDownstreamDependency, error) {
if strings.TrimSpace(stockableType) == "" || len(stockableIDs) == 0 {
return nil, nil
}
db := s.AdjustmentStockRepository.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var rows []adjustmentDownstreamDependency
err := db.Table("stock_allocations").
Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
Where("stockable_type = ?", strings.ToUpper(strings.TrimSpace(stockableType))).
Where("stockable_id IN ?", stockableIDs).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Where(
"(usable_type <> ? OR EXISTS (SELECT 1 FROM project_chickins pc WHERE pc.id = stock_allocations.usable_id AND pc.deleted_at IS NULL))",
fifo.UsableKeyProjectChickin.String(),
).
Group("usable_type, usable_id, function_code, flag_group_code").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func formatAdjustmentDependencySummary(rows []adjustmentDownstreamDependency) string {
func formatAdjustmentDependencySummary(rows []adjustmentStockRepo.AdjustmentDownstreamDependency) string {
if len(rows) == 0 {
return "-"
}
grouped := make(map[string]map[uint64]struct{})
for _, row := range rows {
label := strings.ToUpper(strings.TrimSpace(row.UsableType))
label := utils.NormalizeUpper(row.UsableType)
if label == "" {
label = "UNKNOWN"
}
@@ -841,68 +858,6 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context,
return uint(projectFlockKandang.Id), nil
}
func (s *adjustmentService) resolveAyamSourceProductWarehouse(
ctx context.Context,
tx *gorm.DB,
warehouseID uint,
projectFlockKandangID uint,
) (*entity.ProductWarehouse, error) {
if tx == nil {
return nil, fmt.Errorf("transaction is required")
}
if projectFlockKandangID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id tidak valid untuk depletion conversion")
}
var sourcePW entity.ProductWarehouse
err := tx.WithContext(ctx).
Model(&entity.ProductWarehouse{}).
Where("project_flock_kandang_id = ?", projectFlockKandangID).
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 = product_warehouses.product_id
AND fm.flag_group_code = ?
)
`, entity.FlagableTypeProduct, flagGroupAyam).
Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)).
Order("id ASC").
Take(&sourcePW).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan")
}
return nil, err
}
return &sourcePW, nil
}
func (s *adjustmentService) isAyamProduct(ctx context.Context, tx *gorm.DB, productID uint) (bool, error) {
if productID == 0 {
return false, nil
}
db := s.AdjustmentStockRepository.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var count int64
if err := db.Table("flags f").
Joins("JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.flag_group_code = ? AND fm.is_active = TRUE", flagGroupAyam).
Where("f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.flagable_id = ?", productID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (s *adjustmentService) createAdjustmentStockLog(
ctx context.Context,
stockLogRepo stockLogsRepo.StockLogRepository,
@@ -986,57 +941,6 @@ func (s *adjustmentService) allocatePopulationForDepletionAdjustment(
)
}
func (s *adjustmentService) resyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if tx == nil || projectFlockKandangID == 0 {
return nil
}
idsSubquery := `
SELECT pfp.id
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
`
updateWithAlloc := `
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id
AND p.id IN (` + idsSubquery + `)
`
resetMissing := `
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE p.id IN (` + idsSubquery + `)
AND NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.stockable_id = p.id
)
`
db := tx.WithContext(ctx)
if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil {
return err
}
if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil {
return err
}
return nil
}
func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) {
if err := s.Validate.Struct(query); err != nil {
return nil, 0, err
@@ -1079,11 +983,11 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
}
}
functionCode := strings.ToUpper(strings.TrimSpace(query.TransactionSubtype))
functionCode := utils.NormalizeUpper(query.TransactionSubtype)
if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(query.FunctionCode))
functionCode = utils.NormalizeUpper(query.FunctionCode)
}
transactionType := strings.ToUpper(strings.TrimSpace(query.TransactionType))
transactionType := utils.NormalizeUpper(query.TransactionType)
adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory(
c.Context(),
@@ -725,8 +725,21 @@ FROM (
transferIDs := make(map[uint]struct{})
adjustmentIDs := make(map[uint]struct{})
transferLayingIDs := make(map[uint]struct{})
orphanIDs := make(map[string]map[uint]struct{})
for _, row := range rows {
exists, existsErr := s.usableReferenceExistsForChickinDelete(ctx, db, row.UsableType, row.UsableID)
if existsErr != nil {
s.Log.Errorf("Failed to validate downstream usable reference %s:%d for chickin %d: %+v", row.UsableType, row.UsableID, chickinID, existsErr)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi referensi transaksi turunan chickin")
}
if !exists {
if _, ok := orphanIDs[row.UsableType]; !ok {
orphanIDs[row.UsableType] = make(map[uint]struct{})
}
orphanIDs[row.UsableType][row.UsableID] = struct{}{}
continue
}
switch row.UsableType {
case fifo.UsableKeyMarketingDelivery.String():
marketingIDs[row.UsableID] = struct{}{}
@@ -740,6 +753,24 @@ FROM (
transferLayingIDs[row.UsableID] = struct{}{}
}
}
if len(orphanIDs) > 0 {
orphanDetails := make([]string, 0, len(orphanIDs))
for usableType, idsMap := range orphanIDs {
ids := sortedIDs(idsMap)
if len(ids) == 0 {
continue
}
orphanDetails = append(orphanDetails, fmt.Sprintf("%s=%s", usableType, joinUint(ids)))
}
sort.Strings(orphanDetails)
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Delete chickin diblok karena ditemukan orphan stock allocation pada transaksi turunan: %s. Bersihkan orphan terlebih dahulu.",
strings.Join(orphanDetails, ", "),
),
)
}
details := make([]string, 0, 5)
if ids := sortedIDs(marketingIDs); len(ids) > 0 {
@@ -766,6 +797,72 @@ FROM (
return fiber.NewError(fiber.StatusBadRequest, message)
}
func (s *chickinService) usableReferenceExistsForChickinDelete(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (bool, error) {
if usableID == 0 {
return false, nil
}
if db == nil {
return false, fmt.Errorf("db is required")
}
var count int64
switch usableType {
case fifo.UsableKeyAdjustmentOut.String():
if err := db.WithContext(ctx).
Table("adjustment_stocks").
Where("id = ?", usableID).
Count(&count).Error; err != nil {
return false, err
}
case fifo.UsableKeyMarketingDelivery.String():
if err := db.WithContext(ctx).
Table("marketing_delivery_products").
Where("id = ?", usableID).
Count(&count).Error; err != nil {
return false, err
}
case fifo.UsableKeyRecordingStock.String():
if err := db.WithContext(ctx).
Table("recording_stocks rs").
Joins("JOIN recordings r ON r.id = rs.recording_id").
Where("rs.id = ?", usableID).
Where("r.deleted_at IS NULL").
Count(&count).Error; err != nil {
return false, err
}
case fifo.UsableKeyRecordingDepletion.String():
if err := db.WithContext(ctx).
Table("recording_depletions rd").
Joins("JOIN recordings r ON r.id = rd.recording_id").
Where("rd.id = ?", usableID).
Where("r.deleted_at IS NULL").
Count(&count).Error; err != nil {
return false, err
}
case fifo.UsableKeyStockTransferOut.String():
if err := db.WithContext(ctx).
Table("stock_transfer_details std").
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Where("std.id = ?", usableID).
Where("std.deleted_at IS NULL").
Where("st.deleted_at IS NULL").
Count(&count).Error; err != nil {
return false, err
}
case fifo.UsableKeyTransferToLayingOut.String():
if err := db.WithContext(ctx).
Table("laying_transfers").
Where("id = ?", usableID).
Where("deleted_at IS NULL").
Count(&count).Error; err != nil {
return false, err
}
default:
return true, nil
}
return count > 0, nil
}
func sortedIDs(input map[uint]struct{}) []uint {
if len(input) == 0 {
return nil
+76
View File
@@ -0,0 +1,76 @@
-- Audit orphan stock_allocations (ACTIVE + CONSUME)
-- Usage:
-- psql -U app_lti_user -d db_lti_erp -f scripts/sql/orphan_allocations_audit.sql
\pset pager off
WITH active_alloc AS (
SELECT id, usable_type, usable_id, stockable_type, stockable_id, product_warehouse_id, qty
FROM stock_allocations
WHERE status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
AND deleted_at IS NULL
),
orphan AS (
SELECT a.*
FROM active_alloc a
WHERE
(a.usable_type = 'ADJUSTMENT_OUT' AND NOT EXISTS (SELECT 1 FROM adjustment_stocks ad WHERE ad.id = a.usable_id))
OR (a.usable_type = 'MARKETING_DELIVERY' AND NOT EXISTS (SELECT 1 FROM marketing_delivery_products mdp WHERE mdp.id = a.usable_id))
OR (a.usable_type = 'RECORDING_STOCK' AND NOT EXISTS (
SELECT 1 FROM recording_stocks rs JOIN recordings r ON r.id = rs.recording_id
WHERE rs.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'RECORDING_DEPLETION' AND NOT EXISTS (
SELECT 1 FROM recording_depletions rd JOIN recordings r ON r.id = rd.recording_id
WHERE rd.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'STOCKTRANSFER_OUT' AND NOT EXISTS (
SELECT 1 FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id
WHERE std.id = a.usable_id AND std.deleted_at IS NULL AND st.deleted_at IS NULL
))
OR (a.usable_type = 'TRANSFERTOLAYING_OUT' AND NOT EXISTS (
SELECT 1 FROM laying_transfers lt WHERE lt.id = a.usable_id AND lt.deleted_at IS NULL
))
)
SELECT usable_type, COUNT(*) AS rows, COALESCE(SUM(qty),0) AS total_qty
FROM orphan
GROUP BY usable_type
ORDER BY usable_type;
-- Detail rows (limit)
WITH active_alloc AS (
SELECT id, usable_type, usable_id, stockable_type, stockable_id, product_warehouse_id, qty
FROM stock_allocations
WHERE status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
AND deleted_at IS NULL
),
orphan AS (
SELECT a.*
FROM active_alloc a
WHERE
(a.usable_type = 'ADJUSTMENT_OUT' AND NOT EXISTS (SELECT 1 FROM adjustment_stocks ad WHERE ad.id = a.usable_id))
OR (a.usable_type = 'MARKETING_DELIVERY' AND NOT EXISTS (SELECT 1 FROM marketing_delivery_products mdp WHERE mdp.id = a.usable_id))
OR (a.usable_type = 'RECORDING_STOCK' AND NOT EXISTS (
SELECT 1 FROM recording_stocks rs JOIN recordings r ON r.id = rs.recording_id
WHERE rs.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'RECORDING_DEPLETION' AND NOT EXISTS (
SELECT 1 FROM recording_depletions rd JOIN recordings r ON r.id = rd.recording_id
WHERE rd.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'STOCKTRANSFER_OUT' AND NOT EXISTS (
SELECT 1 FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id
WHERE std.id = a.usable_id AND std.deleted_at IS NULL AND st.deleted_at IS NULL
))
OR (a.usable_type = 'TRANSFERTOLAYING_OUT' AND NOT EXISTS (
SELECT 1 FROM laying_transfers lt WHERE lt.id = a.usable_id AND lt.deleted_at IS NULL
))
)
SELECT *
FROM orphan
ORDER BY usable_type, usable_id, id
LIMIT 200;
@@ -0,0 +1,52 @@
-- Cleanup orphan stock_allocations (ACTIVE + CONSUME) by releasing them.
-- IMPORTANT: run audit first.
-- Usage:
-- psql -U app_lti_user -d db_lti_erp -f scripts/sql/orphan_allocations_cleanup.sql
BEGIN;
WITH active_alloc AS (
SELECT id, usable_type, usable_id
FROM stock_allocations
WHERE status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
AND deleted_at IS NULL
),
orphan AS (
SELECT a.id
FROM active_alloc a
WHERE
(a.usable_type = 'ADJUSTMENT_OUT' AND NOT EXISTS (SELECT 1 FROM adjustment_stocks ad WHERE ad.id = a.usable_id))
OR (a.usable_type = 'MARKETING_DELIVERY' AND NOT EXISTS (SELECT 1 FROM marketing_delivery_products mdp WHERE mdp.id = a.usable_id))
OR (a.usable_type = 'RECORDING_STOCK' AND NOT EXISTS (
SELECT 1 FROM recording_stocks rs JOIN recordings r ON r.id = rs.recording_id
WHERE rs.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'RECORDING_DEPLETION' AND NOT EXISTS (
SELECT 1 FROM recording_depletions rd JOIN recordings r ON r.id = rd.recording_id
WHERE rd.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'STOCKTRANSFER_OUT' AND NOT EXISTS (
SELECT 1 FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id
WHERE std.id = a.usable_id AND std.deleted_at IS NULL AND st.deleted_at IS NULL
))
OR (a.usable_type = 'TRANSFERTOLAYING_OUT' AND NOT EXISTS (
SELECT 1 FROM laying_transfers lt WHERE lt.id = a.usable_id AND lt.deleted_at IS NULL
))
),
updated AS (
UPDATE stock_allocations sa
SET
status = 'RELEASED',
released_at = NOW(),
note = CONCAT(COALESCE(sa.note, ''), CASE WHEN COALESCE(sa.note, '') = '' THEN '' ELSE ' | ' END, 'orphan_cleanup')
WHERE sa.id IN (SELECT id FROM orphan)
RETURNING sa.id, sa.usable_type, sa.usable_id, sa.qty
)
SELECT usable_type, COUNT(*) AS rows, COALESCE(SUM(qty),0) AS total_qty
FROM updated
GROUP BY usable_type
ORDER BY usable_type;
COMMIT;