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
@@ -6,6 +6,7 @@ import (
"fmt"
"math"
"mime/multipart"
"sort"
"strings"
"time"
@@ -29,6 +30,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PurchaseService interface {
@@ -67,6 +69,13 @@ type staffAdjustmentPayload struct {
NewItems []*entity.PurchaseItem
}
type purchaseDownstreamDependency 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 NewPurchaseService(
validate *validator.Validate,
purchaseRepo rPurchase.PurchaseRepository,
@@ -884,8 +893,28 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
if receivedQty > item.SubQty {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty))
}
if receivedQty < item.TotalUsed && isReceivingBelowUsedBlocked(item, lockedIDs) {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed))
if receivedQty < item.TotalUsed {
deps, allowPending, err := s.resolvePurchaseDependenciesAndPendingPolicy(
ctx,
s.PurchaseRepo.DB(),
[]uint{item.Id},
)
if err != nil {
return nil, err
}
if len(deps) > 0 && !allowPending {
return nil, utils.BadRequest(
fmt.Sprintf(
"Received quantity for item %d cannot be lower than used amount (%.3f). Dependensi aktif: %s. Alasan block: pending disabled by config.",
payload.PurchaseItemID,
item.TotalUsed,
formatPurchaseDependencySummary(deps),
),
)
}
if len(deps) == 0 && isReceivingBelowUsedBlocked(item, lockedIDs) {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed))
}
}
if _, dup := visitedItems[payload.PurchaseItemID]; dup {
@@ -1317,6 +1346,30 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
repoTx := rPurchase.NewPurchaseRepository(tx)
var lockedPurchase entity.Purchase
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", purchase.Id).
Take(&lockedPurchase).Error; err != nil {
return err
}
allowPending, deps, err := s.ensurePurchaseDeletePolicy(ctx, tx, toDelete)
if err != nil {
return err
}
if len(deps) > 0 && !allowPending {
return utils.BadRequest(
fmt.Sprintf(
"Purchase item tidak dapat dihapus karena sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.",
formatPurchaseDependencySummary(deps),
),
)
}
if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, fmt.Sprintf("Purchase-Item-Delete#%d", purchase.Id), purchase.CreatedBy); err != nil {
return err
}
if err := repoTx.DeleteItems(ctx, purchase.Id, toDelete); err != nil {
return err
@@ -1385,12 +1438,25 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
}
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, tx, itemsToDelete)
var lockedPurchase entity.Purchase
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", purchase.Id).
Take(&lockedPurchase).Error; err != nil {
return err
}
allowPending, deps, err := s.ensurePurchaseDeletePolicy(ctx, tx, collectPurchaseItemIDs(itemsToDelete))
if err != nil {
return err
}
if len(lockedIDs) > 0 {
return utils.BadRequest("Purchase already chickin, failed to delete purchase")
if len(deps) > 0 && !allowPending {
return utils.BadRequest(
fmt.Sprintf(
"Purchase tidak dapat dihapus karena sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.",
formatPurchaseDependencySummary(deps),
),
)
}
if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil {
@@ -1795,6 +1861,125 @@ func purchaseItemHasFlag(item *entity.PurchaseItem, flag utils.FlagType) bool {
return false
}
func (s *purchaseService) ensurePurchaseDeletePolicy(
ctx context.Context,
tx *gorm.DB,
itemIDs []uint,
) (bool, []purchaseDownstreamDependency, error) {
deps, allowPending, err := s.resolvePurchaseDependenciesAndPendingPolicy(ctx, tx, itemIDs)
if err != nil {
return false, nil, err
}
return allowPending, deps, nil
}
func (s *purchaseService) resolvePurchaseDependenciesAndPendingPolicy(
ctx context.Context,
tx *gorm.DB,
itemIDs []uint,
) ([]purchaseDownstreamDependency, bool, error) {
deps, err := s.loadPurchaseDownstreamDependencies(ctx, tx, itemIDs)
if err != nil {
s.Log.Errorf("Failed to load downstream dependencies for purchase items: %+v", err)
return nil, false, utils.Internal("Failed to validate downstream purchase dependencies")
}
if len(deps) == 0 {
return nil, true, nil
}
allowPending := true
for _, dep := range deps {
policy, policyErr := commonSvc.ResolveFifoPendingPolicy(ctx, tx, commonSvc.FifoPendingPolicyInput{
Lane: "USABLE",
FlagGroupCode: dep.FlagGroupCode,
FunctionCode: dep.FunctionCode,
LegacyTypeKey: dep.UsableType,
})
if policyErr != nil {
s.Log.Errorf("Failed to resolve FIFO pending policy for purchase dependency: %+v", policyErr)
return nil, false, utils.Internal("Failed to read FIFO v2 configuration")
}
if !policy.Found || !policy.AllowPending {
allowPending = false
break
}
}
return deps, allowPending, nil
}
func (s *purchaseService) loadPurchaseDownstreamDependencies(
ctx context.Context,
tx *gorm.DB,
itemIDs []uint,
) ([]purchaseDownstreamDependency, error) {
if len(itemIDs) == 0 {
return nil, nil
}
db := s.PurchaseRepo.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var rows []purchaseDownstreamDependency
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 = ?", fifo.StockableKeyPurchaseItems.String()).
Where("stockable_id IN ?", itemIDs).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Group("usable_type, usable_id, function_code, flag_group_code").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func formatPurchaseDependencySummary(rows []purchaseDownstreamDependency) string {
if len(rows) == 0 {
return "-"
}
dependencyMap := make(map[string]map[uint64]struct{})
for _, row := range rows {
label := strings.ToUpper(strings.TrimSpace(row.UsableType))
if label == "" {
label = "UNKNOWN"
}
if _, ok := dependencyMap[label]; !ok {
dependencyMap[label] = make(map[uint64]struct{})
}
dependencyMap[label][row.UsableID] = struct{}{}
}
labels := make([]string, 0, len(dependencyMap))
for label := range dependencyMap {
labels = append(labels, label)
}
sort.Strings(labels)
parts := make([]string, 0, len(labels))
for _, label := range labels {
ids := make([]uint64, 0, len(dependencyMap[label]))
for id := range dependencyMap[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 isReceivingBelowUsedBlocked(item *entity.PurchaseItem, lockedIDs map[uint]struct{}) bool {
if item == nil {
return false