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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user