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
@@ -103,3 +103,22 @@ func (u *AdjustmentController) GetOne(c *fiber.Ctx) error {
Data: dto.ToAdjustmentDetailDTO(stockLog),
})
}
func (u *AdjustmentController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.AdjustmentService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete adjustment successfully",
})
}
@@ -15,8 +15,9 @@ func AdjustmentRoutes(v1 fiber.Router, u user.UserService, s adjustment.Adjustme
route := v1.Group("/adjustments")
route.Use(m.Auth(u))
// Standard CRUD routes following master data pattern
route.Get("/",m.RequirePermissions(m.P_AdjustmentGetAll), ctrl.AdjustmentHistory) // Get all with pagination and filters
route.Post("/",m.RequirePermissions(m.P_AdjustmentCreate), ctrl.Adjustment) // Create adjustment
route.Get("/:id",m.RequirePermissions(m.P_AdjustmentGetOne), ctrl.GetOne)
route.Get("/", m.RequirePermissions(m.P_AdjustmentGetAll), ctrl.AdjustmentHistory) // Get all with pagination and filters
route.Post("/", m.RequirePermissions(m.P_AdjustmentCreate), ctrl.Adjustment) // Create adjustment
route.Get("/:id", m.RequirePermissions(m.P_AdjustmentGetOne), ctrl.GetOne)
route.Delete("/:id", m.RequirePermissions(m.P_AdjustmentDeleteOne), ctrl.DeleteOne)
}
@@ -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,
@@ -13,7 +13,6 @@ import (
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations"
kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
@@ -259,28 +258,28 @@ func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params
return rows, nil
}
type usageRow struct {
type populationRemainingRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
UsedQty float64 `gorm:"column:used_qty"`
}
usageRows := make([]usageRow, 0)
if err := s.Repository.DB().WithContext(c.Context()).
Table("stock_allocations").
Select("product_warehouse_id, COALESCE(SUM(qty), 0) AS used_qty").
Where("product_warehouse_id IN ?", ayamPWIDs).
Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Group("product_warehouse_id").
Scan(&usageRows).Error; err != nil {
s.Log.Errorf("Failed to calculate available transfer stock after chickin consumption: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghitung stok tersedia untuk transfer")
RemainingQty float64 `gorm:"column:remaining_qty"`
}
usageMap := make(map[uint]float64, len(usageRows))
for _, row := range usageRows {
usageMap[row.ProductWarehouseID] = row.UsedQty
var populationRows []populationRemainingRow
if err := s.Repository.DB().WithContext(c.Context()).
Table("project_flock_populations pfp").
Select("pfp.product_warehouse_id, COALESCE(SUM(GREATEST(pfp.total_qty - pfp.total_used_qty, 0)), 0) AS remaining_qty").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pfp.product_warehouse_id IN ?", ayamPWIDs).
Where("pfp.deleted_at IS NULL").
Where("pc.deleted_at IS NULL").
Group("pfp.product_warehouse_id").
Scan(&populationRows).Error; err != nil {
s.Log.Errorf("Failed to resolve chickin population remaining for transfer stock filter: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer stock availability")
}
populationRemainingByPW := make(map[uint]float64, len(populationRows))
for _, row := range populationRows {
populationRemainingByPW[row.ProductWarehouseID] = row.RemainingQty
}
filtered := make([]entity.ProductWarehouse, 0, len(rows))
@@ -291,7 +290,7 @@ func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params
continue
}
available := row.Quantity - usageMap[row.Id]
available := row.Quantity - populationRemainingByPW[row.Id]
if available < 0 {
available = 0
}
@@ -56,6 +56,13 @@ type transferService struct {
const transferDeleteDownstreamGuardMessage = "Transfer stock tidak dapat dihapus karena stok transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu."
type downstreamDependency 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 NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService {
return &transferService{
Log: utils.Log,
@@ -674,7 +681,7 @@ func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error {
for _, detail := range details {
detailIDs = append(detailIDs, detail.Id)
}
if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), tx, detailIDs); err != nil {
if err := s.ensureDeletePolicyForDownstreamConsumption(c.Context(), tx, detailIDs); err != nil {
return err
}
@@ -936,37 +943,97 @@ func (s *transferService) ensureTransferAccess(ctx context.Context, id uint, c *
return nil
}
func (s *transferService) ensureNoDownstreamConsumptionForDelete(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error {
if len(detailIDs) == 0 {
func (s *transferService) ensureDeletePolicyForDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error {
dependencies, err := s.loadActiveTransferDownstreamDependencies(ctx, tx, detailIDs)
if err != nil {
s.Log.Errorf("Failed to load downstream stock transfer consumption: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock")
}
if len(dependencies) == 0 {
return nil
}
ayamDependency, err := s.hasAyamDownstreamConsumption(ctx, tx, detailIDs)
if err != nil {
s.Log.Errorf("Failed to validate AYAM downstream dependency for transfer delete: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi dependensi AYAM pada transfer stock")
}
if ayamDependency {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"%s Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.",
transferDeleteDownstreamGuardMessage,
formatDownstreamDependencySummary(dependencies),
),
)
}
denyReason := ""
for _, dep := range dependencies {
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 transfer dependency: %+v", policyErr)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membaca konfigurasi FIFO v2")
}
if !policy.Found || !policy.AllowPending {
denyReason = "pending disabled by config"
break
}
}
if denyReason == "" {
return nil
}
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"%s Dependensi aktif: %s. Alasan block: %s.",
transferDeleteDownstreamGuardMessage,
formatDownstreamDependencySummary(dependencies),
denyReason,
),
)
}
func (s *transferService) loadActiveTransferDownstreamDependencies(
ctx context.Context,
tx *gorm.DB,
detailIDs []uint64,
) ([]downstreamDependency, error) {
if len(detailIDs) == 0 {
return nil, nil
}
db := s.StockTransferRepo.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
type downstreamRow struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint64 `gorm:"column:usable_id"`
}
var rows []downstreamRow
if err := db.Table("stock_allocations").
Select("usable_type, usable_id").
var rows []downstreamDependency
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.StockableKeyStockTransferIn.String()).
Where("stockable_id IN ?", detailIDs).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Group("usable_type, usable_id").
Scan(&rows).Error; err != nil {
s.Log.Errorf("Failed to validate downstream stock transfer consumption: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock")
Group("usable_type, usable_id, function_code, flag_group_code").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func formatDownstreamDependencySummary(rows []downstreamDependency) string {
if len(rows) == 0 {
return nil
return "-"
}
dependencyMap := make(map[string]map[uint64]struct{})
@@ -990,10 +1057,35 @@ func (s *transferService) ensureNoDownstreamConsumptionForDelete(ctx context.Con
details = append(details, fmt.Sprintf("%s=%s", label, joinUint64(ids)))
}
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("%s Dependensi aktif: %s.", transferDeleteDownstreamGuardMessage, strings.Join(details, ", ")),
)
return strings.Join(details, ", ")
}
func (s *transferService) hasAyamDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) (bool, error) {
if len(detailIDs) == 0 {
return false, nil
}
db := s.StockTransferRepo.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var found int64
err := db.Table("stock_allocations sa").
Joins("JOIN stock_transfer_details std ON std.id = sa.stockable_id AND std.deleted_at IS NULL").
Joins("JOIN flags f ON f.flagable_type = ? AND f.flagable_id = std.product_id", entity.FlagableTypeProduct).
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("sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Where("sa.stockable_id IN ?", detailIDs).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.deleted_at IS NULL").
Count(&found).Error
if err != nil {
return false, err
}
return found > 0, nil
}
func mapTransferDownstreamUsableLabel(usableType string) string {
@@ -646,24 +646,72 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
}
var rows []downstreamRow
if err := db.Table("stock_allocations sa").
Select("sa.usable_type, sa.usable_id").
Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id").
Where("pfp.project_chickin_id = ?", chickinID).
Where("pfp.deleted_at IS NULL").
Where("sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.deleted_at IS NULL").
Where("sa.usable_type IN ?", []string{
fifo.UsableKeyMarketingDelivery.String(),
fifo.UsableKeyRecordingDepletion.String(),
fifo.UsableKeyStockTransferOut.String(),
fifo.UsableKeyAdjustmentOut.String(),
fifo.UsableKeyTransferToLayingOut.String(),
}).
Group("sa.usable_type, sa.usable_id").
Scan(&rows).Error; err != nil {
dependencyTypes := []string{
fifo.UsableKeyMarketingDelivery.String(),
fifo.UsableKeyRecordingStock.String(),
fifo.UsableKeyRecordingDepletion.String(),
fifo.UsableKeyStockTransferOut.String(),
fifo.UsableKeyAdjustmentOut.String(),
fifo.UsableKeyTransferToLayingOut.String(),
}
query := `
WITH chickin_sources AS (
SELECT DISTINCT sa.stockable_type, sa.stockable_id
FROM stock_allocations sa
WHERE sa.usable_type = ?
AND sa.usable_id = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND sa.deleted_at IS NULL
),
downstream_by_population AS (
SELECT sa.usable_type, sa.usable_id
FROM project_flock_populations pfp
JOIN stock_allocations sa
ON sa.stockable_type = ?
AND sa.stockable_id = pfp.id
WHERE pfp.project_chickin_id = ?
AND pfp.deleted_at IS NULL
AND sa.status = ?
AND sa.allocation_purpose = ?
AND sa.deleted_at IS NULL
AND sa.usable_type IN ?
),
downstream_by_source AS (
SELECT sa.usable_type, sa.usable_id
FROM chickin_sources cs
JOIN stock_allocations sa
ON sa.stockable_type = cs.stockable_type
AND sa.stockable_id = cs.stockable_id
WHERE sa.status = ?
AND sa.allocation_purpose = ?
AND sa.deleted_at IS NULL
AND sa.usable_type IN ?
)
SELECT dep.usable_type, dep.usable_id
FROM (
SELECT usable_type, usable_id FROM downstream_by_population
UNION
SELECT usable_type, usable_id FROM downstream_by_source
) dep
`
if err := db.Raw(
query,
fifo.UsableKeyProjectChickin.String(),
chickinID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyProjectFlockPopulation.String(),
chickinID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
dependencyTypes,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
dependencyTypes,
).Scan(&rows).Error; err != nil {
s.Log.Errorf("Failed to validate downstream consumption for chickin %d: %+v", chickinID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan chickin")
}
@@ -682,7 +730,7 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
switch row.UsableType {
case fifo.UsableKeyMarketingDelivery.String():
marketingIDs[row.UsableID] = struct{}{}
case fifo.UsableKeyRecordingDepletion.String():
case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String():
recordingIDs[row.UsableID] = struct{}{}
case fifo.UsableKeyStockTransferOut.String():
transferIDs[row.UsableID] = struct{}{}
@@ -522,9 +522,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
recordingEntity = recording
if err := s.ensurePopulationMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
return err
}
pfkForRoute := recordingEntity.ProjectFlockKandang
if pfkForRoute == nil || pfkForRoute.Id == 0 {
fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId)
@@ -537,7 +534,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
pfkForRoute = fetchedPfk
}
routePayload := buildRecordingRoutePayloadFromUpdate(req, recordingEntity)
routePayload := buildRecordingRoutePayloadFromUpdate(req)
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
return err
}
@@ -594,6 +591,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if match {
hasDepletionChanges = false
} else {
if err := s.ensurePopulationMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
return err
}
if err := s.ensureDepletionMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
return err
}
@@ -935,15 +935,15 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
s.Log.Errorf("Failed to find recording: %+v", err)
return err
}
if err := s.ensurePopulationMutationAllowed(ctx, recording, "hapus"); err != nil {
return err
}
existingDepletions, err := s.Repository.ListDepletions(tx, recording.Id)
if err != nil {
s.Log.Errorf("Failed to list existing depletions: %+v", err)
return err
}
if len(existingDepletions) > 0 {
if err := s.ensurePopulationMutationAllowed(ctx, recording, "hapus"); err != nil {
return err
}
if err := s.ensureDepletionMutationAllowed(ctx, recording, "hapus"); err != nil {
return err
}
@@ -1376,60 +1376,34 @@ func buildRecordingRoutePayloadFromCreate(req *validation.Create) recordingRoute
return payload
}
func buildRecordingRoutePayloadFromUpdate(req *validation.Update, existing *entity.Recording) recordingRoutePayload {
func buildRecordingRoutePayloadFromUpdate(req *validation.Update) recordingRoutePayload {
payload := recordingRoutePayload{}
if req == nil && existing == nil {
if req == nil {
return payload
}
if req != nil && req.Stocks != nil {
if req.Stocks != nil {
for _, stock := range req.Stocks {
if stock.Qty > 0 {
payload.StockCount++
}
}
} else if existing != nil {
for _, stock := range existing.Stocks {
usageQty := 0.0
if stock.UsageQty != nil {
usageQty = *stock.UsageQty
}
pendingQty := 0.0
if stock.PendingQty != nil {
pendingQty = *stock.PendingQty
}
if usageQty > 0 || pendingQty > 0 {
payload.StockCount++
}
}
}
if req != nil && req.Depletions != nil {
if req.Depletions != nil {
for _, depletion := range req.Depletions {
if depletion.Qty > 0 {
payload.DepletionCount++
}
}
} else if existing != nil {
for _, depletion := range existing.Depletions {
if depletion.Qty > 0 {
payload.DepletionCount++
}
}
}
if req != nil && req.Eggs != nil {
if req.Eggs != nil {
for _, egg := range req.Eggs {
if egg.Qty > 0 {
payload.EggCount++
}
}
} else if existing != nil {
for _, egg := range existing.Eggs {
if egg.Qty > 0 {
payload.EggCount++
}
}
}
return payload
@@ -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