mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
add paired adjustment triger depletion adjustment
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+14
@@ -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;
|
||||
+86
@@ -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;
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
+214
-25
@@ -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"`
|
||||
stockableType string,
|
||||
stockableIDs []uint,
|
||||
) ([]AdjustmentDownstreamDependency, error) {
|
||||
if strings.TrimSpace(stockableType) == "" || len(stockableIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var selected selectedRow
|
||||
var rows []AdjustmentDownstreamDependency
|
||||
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
|
||||
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 {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, 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,23 +77,21 @@ func NewAdjustmentService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
|
||||
return db.
|
||||
Preload("ProductWarehouse").
|
||||
Preload("ProductWarehouse.Product").
|
||||
Preload("ProductWarehouse.Warehouse").
|
||||
Preload("ProductWarehouse.Warehouse.Location").
|
||||
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)
|
||||
adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
|
||||
return db.
|
||||
Preload("ProductWarehouse").
|
||||
Preload("ProductWarehouse.Product").
|
||||
Preload("ProductWarehouse.Warehouse").
|
||||
Preload("ProductWarehouse.Warehouse.Location").
|
||||
Preload("ProductWarehouse.ProjectFlockKandang").
|
||||
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock").
|
||||
Preload("StockLog.CreatedUser")
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
|
||||
@@ -132,154 +121,232 @@ 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)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to resolve AYAM flag for product %d: %+v", prod.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))
|
||||
|
||||
switch routeMeta.Lane {
|
||||
case adjustmentLaneStockable:
|
||||
deps, allowPending, err := s.resolveAdjustmentDependenciesAndPolicy(
|
||||
ctx,
|
||||
tx,
|
||||
fifo.StockableKeyAdjustmentIn.String(),
|
||||
[]uint{adjustment.Id},
|
||||
)
|
||||
if err != nil {
|
||||
for _, item := range adjustments {
|
||||
if err := s.deleteSingleAdjustmentInTx(ctx, tx, item, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(deps) > 0 && isAyamProduct {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf(
|
||||
"Adjustment tidak dapat dihapus karena produk AYAM sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.",
|
||||
formatAdjustmentDependencySummary(deps),
|
||||
),
|
||||
)
|
||||
}
|
||||
if len(deps) > 0 && !allowPending {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf(
|
||||
"Adjustment tidak dapat dihapus karena stok adjustment sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.",
|
||||
formatAdjustmentDependencySummary(deps),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
asOf := adjustment.CreatedAt
|
||||
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
|
||||
FlagGroupCode: routeMeta.FlagGroupCode,
|
||||
ProductWarehouseID: adjustment.ProductWarehouseId,
|
||||
AsOf: &asOf,
|
||||
Tx: tx,
|
||||
}); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err))
|
||||
}
|
||||
if err := s.createAdjustmentStockLog(
|
||||
ctx,
|
||||
stockLogRepoTx,
|
||||
adjustment.Id,
|
||||
adjustment.ProductWarehouseId,
|
||||
notes,
|
||||
actorID,
|
||||
0,
|
||||
oldQty,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case adjustmentLaneUsable:
|
||||
rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, common.FifoStockV2RollbackRequest{
|
||||
ProductWarehouseID: adjustment.ProductWarehouseId,
|
||||
Usable: common.FifoStockV2Ref{
|
||||
ID: adjustment.Id,
|
||||
LegacyTypeKey: routeMeta.LegacyTypeKey,
|
||||
FunctionCode: routeMeta.FunctionCode,
|
||||
},
|
||||
Reason: notes,
|
||||
Tx: tx,
|
||||
})
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to rollback FIFO v2 adjustment: %v", err))
|
||||
}
|
||||
|
||||
releasedQty := 0.0
|
||||
if rollbackRes != nil {
|
||||
releasedQty = rollbackRes.ReleasedQty
|
||||
}
|
||||
if releasedQty > 0 {
|
||||
if err := s.createAdjustmentStockLog(
|
||||
ctx,
|
||||
stockLogRepoTx,
|
||||
adjustment.Id,
|
||||
adjustment.ProductWarehouseId,
|
||||
notes,
|
||||
actorID,
|
||||
releasedQty,
|
||||
0,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
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 {
|
||||
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 {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment")
|
||||
}
|
||||
|
||||
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 {
|
||||
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", utils.NormalizeTrim(adjustment.AdjNumber))
|
||||
|
||||
switch routeMeta.Lane {
|
||||
case adjustmentLaneStockable:
|
||||
deps, allowPending, err := s.resolveAdjustmentDependenciesAndPolicy(
|
||||
ctx,
|
||||
tx,
|
||||
fifo.StockableKeyAdjustmentIn.String(),
|
||||
[]uint{adjustment.Id},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(deps) > 0 && isAyamProduct {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf(
|
||||
"Adjustment tidak dapat dihapus karena produk AYAM sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.",
|
||||
formatAdjustmentDependencySummary(deps),
|
||||
),
|
||||
)
|
||||
}
|
||||
if len(deps) > 0 && !allowPending {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf(
|
||||
"Adjustment tidak dapat dihapus karena stok adjustment sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.",
|
||||
formatAdjustmentDependencySummary(deps),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
oldQty := adjustment.TotalQty
|
||||
if oldQty > 0 {
|
||||
if err := repoTx.UpdateTotalQty(ctx, adjustment.Id, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
asOf := adjustment.CreatedAt
|
||||
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
|
||||
FlagGroupCode: routeMeta.FlagGroupCode,
|
||||
ProductWarehouseID: adjustment.ProductWarehouseId,
|
||||
AsOf: &asOf,
|
||||
Tx: tx,
|
||||
}); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err))
|
||||
}
|
||||
if err := s.createAdjustmentStockLog(
|
||||
ctx,
|
||||
stockLogRepoTx,
|
||||
adjustment.Id,
|
||||
adjustment.ProductWarehouseId,
|
||||
notes,
|
||||
actorID,
|
||||
0,
|
||||
oldQty,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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: fifo.UsableKeyAdjustmentOut.String(),
|
||||
},
|
||||
Reason: notes,
|
||||
Tx: tx,
|
||||
})
|
||||
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 {
|
||||
releasedQty = rollbackRes.ReleasedQty
|
||||
}
|
||||
if releasedQty > 0 {
|
||||
if err := s.createAdjustmentStockLog(
|
||||
ctx,
|
||||
stockLogRepoTx,
|
||||
adjustment.Id,
|
||||
adjustment.ProductWarehouseId,
|
||||
notes,
|
||||
actorID,
|
||||
releasedQty,
|
||||
0,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Unsupported adjustment lane")
|
||||
}
|
||||
|
||||
if err := repoTx.DeleteStockLogsByAdjustmentID(ctx, adjustment.Id); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment stock logs")
|
||||
}
|
||||
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) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
return nil, err
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user