add restrict create/edit/delete depletion

This commit is contained in:
ragilap
2026-03-14 15:38:47 +07:00
parent 29956528e5
commit 5ba10113c3
12 changed files with 1099 additions and 109 deletions
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math"
"sort"
"strings"
"github.com/go-playground/validator/v10"
@@ -24,11 +25,13 @@ 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 {
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error)
}
@@ -51,6 +54,13 @@ const (
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,
@@ -104,6 +114,172 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentSto
return adjustmentStock, nil
}
func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error {
if id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid adjustment id")
}
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
return err
}
ctx := c.Context()
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return err
}
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)
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 {
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) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
@@ -527,6 +703,118 @@ func (s *adjustmentService) resolveOverconsumePolicy(
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)
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")
}
if len(deps) == 0 {
return nil, true, nil
}
allowPending := true
for _, dep := range deps {
policy, policyErr := common.ResolveFifoPendingPolicy(ctx, tx, common.FifoPendingPolicyInput{
Lane: adjustmentLaneUsable,
FlagGroupCode: dep.FlagGroupCode,
FunctionCode: dep.FunctionCode,
LegacyTypeKey: dep.UsableType,
})
if policyErr != nil {
s.Log.Errorf("Failed to resolve FIFO pending policy for adjustment dependency: %+v", policyErr)
return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to read FIFO v2 configuration")
}
if !policy.Found || !policy.AllowPending {
allowPending = false
break
}
}
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 {
if len(rows) == 0 {
return "-"
}
grouped := make(map[string]map[uint64]struct{})
for _, row := range rows {
label := strings.ToUpper(strings.TrimSpace(row.UsableType))
if label == "" {
label = "UNKNOWN"
}
if _, ok := grouped[label]; !ok {
grouped[label] = make(map[uint64]struct{})
}
grouped[label][row.UsableID] = struct{}{}
}
labels := make([]string, 0, len(grouped))
for label := range grouped {
labels = append(labels, label)
}
sort.Strings(labels)
parts := make([]string, 0, len(labels))
for _, label := range labels {
ids := make([]uint64, 0, len(grouped[label]))
for id := range grouped[label] {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
idParts := make([]string, 0, len(ids))
for _, id := range ids {
idParts = append(idParts, fmt.Sprintf("%d", id))
}
parts = append(parts, fmt.Sprintf("%s=%s", label, strings.Join(idParts, "|")))
}
return strings.Join(parts, ", ")
}
func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil)
if err != nil {
@@ -593,6 +881,28 @@ func (s *adjustmentService) resolveAyamSourceProductWarehouse(
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,