mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
add restrict create/edit/delete depletion
This commit is contained in:
@@ -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,
|
||||
|
||||
+20
-21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user