mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 05:21:57 +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"`
|
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||||
}
|
}
|
||||||
var latest row
|
var latest row
|
||||||
err := tx.WithContext(ctx).
|
latestQuery := tx.WithContext(ctx).
|
||||||
Table("stock_allocations").
|
Table("stock_allocations").
|
||||||
Select("flag_group_code").
|
Select("flag_group_code").
|
||||||
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
|
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
|
||||||
Where("engine_version = 'v2'").
|
Where("engine_version = 'v2'").
|
||||||
Where("allocation_purpose = ?", defaultAllocationPurpose()).
|
Where("allocation_purpose = ?", defaultAllocationPurpose()).
|
||||||
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''").
|
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''")
|
||||||
Order("id DESC").
|
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
|
||||||
Limit(1).
|
latestQuery = latestQuery.Where("function_code = ?", code)
|
||||||
Take(&latest).Error
|
}
|
||||||
|
err := latestQuery.Order("id DESC").Limit(1).Take(&latest).Error
|
||||||
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
|
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
|
||||||
return latest.FlagGroupCode, nil
|
return latest.FlagGroupCode, nil
|
||||||
}
|
}
|
||||||
@@ -718,19 +719,56 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules []routeRule
|
rulesQuery := tx.WithContext(ctx).
|
||||||
err = tx.WithContext(ctx).
|
|
||||||
Table("fifo_stock_v2_route_rules").
|
Table("fifo_stock_v2_route_rules").
|
||||||
Where("is_active = TRUE").
|
Where("is_active = TRUE").
|
||||||
Where("lane = ?", string(LaneUsable)).
|
Where("lane = ?", string(LaneUsable)).
|
||||||
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey).
|
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey)
|
||||||
Find(&rules).Error
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if len(rules) == 0 {
|
if len(rules) == 0 {
|
||||||
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey)
|
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 {
|
if len(rules) > 1 {
|
||||||
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
|
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 {
|
type AdjustmentStock struct {
|
||||||
Id uint `gorm:"primaryKey"`
|
Id uint `gorm:"primaryKey"`
|
||||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
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"`
|
TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"`
|
||||||
FunctionCode string `gorm:"column:function_code;type:varchar(64)"`
|
FunctionCode string `gorm:"column:function_code;type:varchar(64)"`
|
||||||
TotalQty float64 `gorm:"column:total_qty;default:0"`
|
TotalQty float64 `gorm:"column:total_qty;default:0"`
|
||||||
@@ -18,5 +19,6 @@ type AdjustmentStock struct {
|
|||||||
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
|
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
|
||||||
|
|
||||||
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
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"`
|
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
|
||||||
}
|
}
|
||||||
|
|||||||
+214
-25
@@ -2,12 +2,12 @@ package repositories
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -15,9 +15,19 @@ import (
|
|||||||
type AdjustmentStockRepository interface {
|
type AdjustmentStockRepository interface {
|
||||||
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error
|
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)
|
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)
|
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)
|
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)
|
FindHistory(ctx context.Context, filter AdjustmentHistoryFilter, modifier func(*gorm.DB) *gorm.DB) ([]*entity.AdjustmentStock, int64, error)
|
||||||
WithTx(tx *gorm.DB) AdjustmentStockRepository
|
WithTx(tx *gorm.DB) AdjustmentStockRepository
|
||||||
DB() *gorm.DB
|
DB() *gorm.DB
|
||||||
@@ -44,6 +54,13 @@ type AdjustmentHistoryFilter struct {
|
|||||||
Limit int
|
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 {
|
type adjustmentStockRepositoryImpl struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
@@ -73,6 +90,17 @@ func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, mo
|
|||||||
return &record, nil
|
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) {
|
func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
|
||||||
type pfkRow struct {
|
type pfkRow struct {
|
||||||
KandangID uint `gorm:"column:kandang_id"`
|
KandangID uint `gorm:"column:kandang_id"`
|
||||||
@@ -91,6 +119,21 @@ func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx
|
|||||||
return pfk.KandangID, nil
|
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(
|
func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
productID uint,
|
productID uint,
|
||||||
@@ -122,37 +165,183 @@ func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *adjustmentStockRepositoryImpl) FindOverconsumeRule(
|
func (r *adjustmentStockRepositoryImpl) LoadDownstreamDependencies(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
lane string,
|
stockableType string,
|
||||||
flagGroupCode string,
|
stockableIDs []uint,
|
||||||
functionCode string,
|
) ([]AdjustmentDownstreamDependency, error) {
|
||||||
) (*bool, error) {
|
if strings.TrimSpace(stockableType) == "" || len(stockableIDs) == 0 {
|
||||||
type selectedRow struct {
|
return nil, nil
|
||||||
AllowOverconsume bool `gorm:"column:allow_overconsume"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected selectedRow
|
var rows []AdjustmentDownstreamDependency
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Table("fifo_stock_v2_overconsume_rules").
|
Table("stock_allocations").
|
||||||
Select("allow_overconsume").
|
Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
|
||||||
Where("is_active = TRUE").
|
Where("stockable_type = ?", strings.ToUpper(strings.TrimSpace(stockableType))).
|
||||||
Where("lane = ?", lane).
|
Where("stockable_id IN ?", stockableIDs).
|
||||||
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
|
Where("status = ?", entity.StockAllocationStatusActive).
|
||||||
Where("(function_code IS NULL OR function_code = ?)", functionCode).
|
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||||
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
|
Where("deleted_at IS NULL").
|
||||||
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
|
Where(
|
||||||
Order("priority ASC, id ASC").
|
"(usable_type <> ? OR EXISTS (SELECT 1 FROM project_chickins pc WHERE pc.id = stock_allocations.usable_id AND pc.deleted_at IS NULL))",
|
||||||
Limit(1).
|
"PROJECT_CHICKIN",
|
||||||
Take(&selected).Error
|
).
|
||||||
|
Group("usable_type, usable_id, function_code, flag_group_code").
|
||||||
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
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(
|
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"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AdjustmentService interface {
|
type AdjustmentService interface {
|
||||||
@@ -51,16 +50,8 @@ type adjustmentService struct {
|
|||||||
const (
|
const (
|
||||||
adjustmentLaneStockable = "STOCKABLE"
|
adjustmentLaneStockable = "STOCKABLE"
|
||||||
adjustmentLaneUsable = "USABLE"
|
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(
|
func NewAdjustmentService(
|
||||||
productRepo productRepo.ProductRepository,
|
productRepo productRepo.ProductRepository,
|
||||||
stockLogsRepo stockLogsRepo.StockLogRepository,
|
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) {
|
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) {
|
||||||
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
|
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
|
||||||
return nil, err
|
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 err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
|
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 {
|
return s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var adjustment entity.AdjustmentStock
|
adjustments, err := s.collectAdjustmentsForDelete(ctx, tx, id)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
isAyamProduct, err := s.isAyamProduct(ctx, tx, prod.ProductID)
|
for _, item := range adjustments {
|
||||||
if err != nil {
|
if err := s.deleteSingleAdjustmentInTx(ctx, tx, item, actorID); 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 {
|
|
||||||
return err
|
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
|
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) {
|
func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) {
|
||||||
if err := s.Validate.Struct(req); err != nil {
|
if err := s.Validate.Struct(req); err != nil {
|
||||||
return nil, err
|
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")
|
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 == "" {
|
if functionCode == "" {
|
||||||
functionCode = strings.ToUpper(strings.TrimSpace(req.TransactionSubType))
|
functionCode = utils.NormalizeUpper(req.TransactionSubType)
|
||||||
}
|
}
|
||||||
if functionCode == "" {
|
if functionCode == "" {
|
||||||
functionCode = strings.ToUpper(strings.TrimSpace(req.FunctionCode))
|
functionCode = utils.NormalizeUpper(req.FunctionCode)
|
||||||
}
|
}
|
||||||
if functionCode == "" {
|
if functionCode == "" {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required")
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
note := strings.TrimSpace(req.Notes)
|
note := utils.NormalizeTrim(req.Notes)
|
||||||
if note == "" {
|
if note == "" {
|
||||||
note = strings.TrimSpace(req.Note)
|
note = utils.NormalizeTrim(req.Note)
|
||||||
}
|
}
|
||||||
grandTotal := math.Round((qty*req.Price)*1000) / 1000
|
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")
|
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 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
|
return err
|
||||||
}
|
}
|
||||||
if err := common.EnsureProjectFlockNotClosedForProductWarehouses(
|
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 {
|
if err := adjustmentStockRepoTX.CreateOne(ctx, destinationAdjustment, nil); err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion destination adjustment stock record")
|
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
|
sourceAsOf := sourceAdjustment.CreatedAt
|
||||||
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
|
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 {
|
); err != nil {
|
||||||
return err
|
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")
|
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(
|
func (s *adjustmentService) resolveAdjustmentDependenciesAndPolicy(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
stockableType string,
|
stockableType string,
|
||||||
stockableIDs []uint,
|
stockableIDs []uint,
|
||||||
) ([]adjustmentDownstreamDependency, bool, error) {
|
) ([]adjustmentStockRepo.AdjustmentDownstreamDependency, bool, error) {
|
||||||
deps, err := s.loadAdjustmentDownstreamDependencies(ctx, tx, stockableType, stockableIDs)
|
deps, err := s.AdjustmentStockRepository.WithTx(tx).LoadDownstreamDependencies(ctx, stockableType, stockableIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Log.Errorf("Failed to load downstream adjustment dependencies: %+v", err)
|
s.Log.Errorf("Failed to load downstream adjustment dependencies: %+v", err)
|
||||||
return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate downstream adjustment dependencies")
|
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
|
return deps, allowPending, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *adjustmentService) loadAdjustmentDownstreamDependencies(
|
func formatAdjustmentDependencySummary(rows []adjustmentStockRepo.AdjustmentDownstreamDependency) string {
|
||||||
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 {
|
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return "-"
|
return "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
grouped := make(map[string]map[uint64]struct{})
|
grouped := make(map[string]map[uint64]struct{})
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
label := strings.ToUpper(strings.TrimSpace(row.UsableType))
|
label := utils.NormalizeUpper(row.UsableType)
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = "UNKNOWN"
|
label = "UNKNOWN"
|
||||||
}
|
}
|
||||||
@@ -841,68 +858,6 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context,
|
|||||||
return uint(projectFlockKandang.Id), nil
|
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(
|
func (s *adjustmentService) createAdjustmentStockLog(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
stockLogRepo stockLogsRepo.StockLogRepository,
|
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) {
|
func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) {
|
||||||
if err := s.Validate.Struct(query); err != nil {
|
if err := s.Validate.Struct(query); err != nil {
|
||||||
return nil, 0, err
|
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 == "" {
|
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(
|
adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory(
|
||||||
c.Context(),
|
c.Context(),
|
||||||
|
|||||||
@@ -725,8 +725,21 @@ FROM (
|
|||||||
transferIDs := make(map[uint]struct{})
|
transferIDs := make(map[uint]struct{})
|
||||||
adjustmentIDs := make(map[uint]struct{})
|
adjustmentIDs := make(map[uint]struct{})
|
||||||
transferLayingIDs := make(map[uint]struct{})
|
transferLayingIDs := make(map[uint]struct{})
|
||||||
|
orphanIDs := make(map[string]map[uint]struct{})
|
||||||
|
|
||||||
for _, row := range rows {
|
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 {
|
switch row.UsableType {
|
||||||
case fifo.UsableKeyMarketingDelivery.String():
|
case fifo.UsableKeyMarketingDelivery.String():
|
||||||
marketingIDs[row.UsableID] = struct{}{}
|
marketingIDs[row.UsableID] = struct{}{}
|
||||||
@@ -740,6 +753,24 @@ FROM (
|
|||||||
transferLayingIDs[row.UsableID] = struct{}{}
|
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)
|
details := make([]string, 0, 5)
|
||||||
if ids := sortedIDs(marketingIDs); len(ids) > 0 {
|
if ids := sortedIDs(marketingIDs); len(ids) > 0 {
|
||||||
@@ -766,6 +797,72 @@ FROM (
|
|||||||
return fiber.NewError(fiber.StatusBadRequest, message)
|
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 {
|
func sortedIDs(input map[uint]struct{}) []uint {
|
||||||
if len(input) == 0 {
|
if len(input) == 0 {
|
||||||
return nil
|
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