mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-24 23:35:43 +00:00
add restrict create/edit/delete depletion
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FifoPendingPolicyInput struct {
|
||||||
|
Lane string
|
||||||
|
FlagGroupCode string
|
||||||
|
FunctionCode string
|
||||||
|
LegacyTypeKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FifoPendingPolicyResult struct {
|
||||||
|
AllowPending bool
|
||||||
|
RuleSource string
|
||||||
|
Found bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveFifoPendingPolicy(ctx context.Context, tx *gorm.DB, input FifoPendingPolicyInput) (*FifoPendingPolicyResult, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return nil, gorm.ErrInvalidDB
|
||||||
|
}
|
||||||
|
|
||||||
|
lane := strings.ToUpper(strings.TrimSpace(input.Lane))
|
||||||
|
flagGroupCode := strings.ToUpper(strings.TrimSpace(input.FlagGroupCode))
|
||||||
|
functionCode := strings.ToUpper(strings.TrimSpace(input.FunctionCode))
|
||||||
|
legacyTypeKey := strings.ToUpper(strings.TrimSpace(input.LegacyTypeKey))
|
||||||
|
if lane == "" {
|
||||||
|
return &FifoPendingPolicyResult{
|
||||||
|
AllowPending: false,
|
||||||
|
RuleSource: "SAFE_DEFAULT_BLOCK",
|
||||||
|
Found: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type overconsumeRuleRow struct {
|
||||||
|
Allow bool `gorm:"column:allow_overconsume"`
|
||||||
|
}
|
||||||
|
var overconsume overconsumeRuleRow
|
||||||
|
overconsumeErr := tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_overconsume_rules").
|
||||||
|
Select("allow_overconsume").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Where("lane = ?", lane).
|
||||||
|
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
|
||||||
|
Where("(function_code IS NULL OR function_code = ?)", functionCode).
|
||||||
|
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
|
||||||
|
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
|
||||||
|
Order("priority ASC, id ASC").
|
||||||
|
Limit(1).
|
||||||
|
Take(&overconsume).Error
|
||||||
|
if overconsumeErr == nil {
|
||||||
|
return &FifoPendingPolicyResult{
|
||||||
|
AllowPending: overconsume.Allow,
|
||||||
|
RuleSource: "OVERCONSUME_RULE",
|
||||||
|
Found: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(overconsumeErr, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, overconsumeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeRuleRow struct {
|
||||||
|
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
|
||||||
|
}
|
||||||
|
var routeRule routeRuleRow
|
||||||
|
routeQuery := tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_route_rules").
|
||||||
|
Select("allow_pending_default").
|
||||||
|
Where("is_active = TRUE").
|
||||||
|
Where("lane = ?", lane).
|
||||||
|
Where("flag_group_code = ?", flagGroupCode)
|
||||||
|
if legacyTypeKey != "" {
|
||||||
|
routeQuery = routeQuery.Where("legacy_type_key = ?", legacyTypeKey)
|
||||||
|
}
|
||||||
|
if functionCode != "" {
|
||||||
|
routeQuery = routeQuery.Where("function_code = ?", functionCode)
|
||||||
|
}
|
||||||
|
routeErr := routeQuery.
|
||||||
|
Order("id ASC").
|
||||||
|
Limit(1).
|
||||||
|
Take(&routeRule).Error
|
||||||
|
if routeErr == nil {
|
||||||
|
return &FifoPendingPolicyResult{
|
||||||
|
AllowPending: routeRule.AllowPendingDefault,
|
||||||
|
RuleSource: "ROUTE_RULE_DEFAULT",
|
||||||
|
Found: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(routeErr, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, routeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FifoPendingPolicyResult{
|
||||||
|
AllowPending: false,
|
||||||
|
RuleSource: "SAFE_DEFAULT_BLOCK",
|
||||||
|
Found: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- MARKETING_OUT: if AYAM-only rule exists, convert back to global rule.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
flag_group_code = NULL,
|
||||||
|
allow_overconsume = FALSE,
|
||||||
|
priority = 20,
|
||||||
|
reason = 'fifo_v2_exception_marketing_block',
|
||||||
|
is_active = TRUE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'MARKETING_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_marketing_block_ayam_only';
|
||||||
|
|
||||||
|
-- MARKETING_OUT: if global row already exists, keep it active.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
allow_overconsume = FALSE,
|
||||||
|
priority = 20,
|
||||||
|
is_active = TRUE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'MARKETING_OUT'
|
||||||
|
AND flag_group_code IS NULL
|
||||||
|
AND reason = 'fifo_v2_exception_marketing_block';
|
||||||
|
|
||||||
|
-- MARKETING_OUT: insert global rule if still missing.
|
||||||
|
INSERT INTO fifo_stock_v2_overconsume_rules(
|
||||||
|
flag_group_code,
|
||||||
|
function_code,
|
||||||
|
lane,
|
||||||
|
allow_overconsume,
|
||||||
|
priority,
|
||||||
|
reason,
|
||||||
|
is_active
|
||||||
|
)
|
||||||
|
SELECT NULL, 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block', TRUE
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM fifo_stock_v2_overconsume_rules
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'MARKETING_OUT'
|
||||||
|
AND flag_group_code IS NULL
|
||||||
|
AND reason = 'fifo_v2_exception_marketing_block'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- MARKETING_OUT: deactivate AYAM-only duplicates if any remain.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
is_active = FALSE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'MARKETING_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_marketing_block_ayam_only';
|
||||||
|
|
||||||
|
-- STOCK_TRANSFER_OUT: if AYAM-only rule exists, convert back to global rule.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
flag_group_code = NULL,
|
||||||
|
allow_overconsume = FALSE,
|
||||||
|
priority = 30,
|
||||||
|
reason = 'fifo_v2_exception_transfer_block',
|
||||||
|
is_active = TRUE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'STOCK_TRANSFER_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_transfer_block_ayam_only';
|
||||||
|
|
||||||
|
-- STOCK_TRANSFER_OUT: if global row already exists, keep it active.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
allow_overconsume = FALSE,
|
||||||
|
priority = 30,
|
||||||
|
is_active = TRUE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'STOCK_TRANSFER_OUT'
|
||||||
|
AND flag_group_code IS NULL
|
||||||
|
AND reason = 'fifo_v2_exception_transfer_block';
|
||||||
|
|
||||||
|
-- STOCK_TRANSFER_OUT: insert global rule if still missing.
|
||||||
|
INSERT INTO fifo_stock_v2_overconsume_rules(
|
||||||
|
flag_group_code,
|
||||||
|
function_code,
|
||||||
|
lane,
|
||||||
|
allow_overconsume,
|
||||||
|
priority,
|
||||||
|
reason,
|
||||||
|
is_active
|
||||||
|
)
|
||||||
|
SELECT NULL, 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block', TRUE
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM fifo_stock_v2_overconsume_rules
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'STOCK_TRANSFER_OUT'
|
||||||
|
AND flag_group_code IS NULL
|
||||||
|
AND reason = 'fifo_v2_exception_transfer_block'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- STOCK_TRANSFER_OUT: deactivate AYAM-only duplicates if any remain.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
is_active = FALSE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'STOCK_TRANSFER_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_transfer_block_ayam_only';
|
||||||
|
|
||||||
|
-- CHICKIN_OUT: rollback AYAM-only hard-block added by up migration.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
is_active = FALSE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'CHICKIN_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_chickin_block_ayam_only';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- MARKETING_OUT: if global rule exists, convert to AYAM-specific.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
flag_group_code = 'AYAM',
|
||||||
|
allow_overconsume = FALSE,
|
||||||
|
priority = 20,
|
||||||
|
reason = 'fifo_v2_exception_marketing_block_ayam_only',
|
||||||
|
is_active = TRUE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'MARKETING_OUT'
|
||||||
|
AND flag_group_code IS NULL
|
||||||
|
AND reason = 'fifo_v2_exception_marketing_block';
|
||||||
|
|
||||||
|
-- MARKETING_OUT: if AYAM-specific row already exists, enforce desired value.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
allow_overconsume = FALSE,
|
||||||
|
priority = 20,
|
||||||
|
is_active = TRUE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'MARKETING_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_marketing_block_ayam_only';
|
||||||
|
|
||||||
|
-- MARKETING_OUT: insert AYAM-specific if no suitable row exists.
|
||||||
|
INSERT INTO fifo_stock_v2_overconsume_rules(
|
||||||
|
flag_group_code,
|
||||||
|
function_code,
|
||||||
|
lane,
|
||||||
|
allow_overconsume,
|
||||||
|
priority,
|
||||||
|
reason,
|
||||||
|
is_active
|
||||||
|
)
|
||||||
|
SELECT 'AYAM', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_ayam_only', TRUE
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM fifo_stock_v2_overconsume_rules
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'MARKETING_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_marketing_block_ayam_only'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- MARKETING_OUT: deactivate remaining global rule (if any duplicate row exists).
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
is_active = FALSE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'MARKETING_OUT'
|
||||||
|
AND flag_group_code IS NULL
|
||||||
|
AND reason = 'fifo_v2_exception_marketing_block';
|
||||||
|
|
||||||
|
-- STOCK_TRANSFER_OUT: if global rule exists, convert to AYAM-specific.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
flag_group_code = 'AYAM',
|
||||||
|
allow_overconsume = FALSE,
|
||||||
|
priority = 30,
|
||||||
|
reason = 'fifo_v2_exception_transfer_block_ayam_only',
|
||||||
|
is_active = TRUE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'STOCK_TRANSFER_OUT'
|
||||||
|
AND flag_group_code IS NULL
|
||||||
|
AND reason = 'fifo_v2_exception_transfer_block';
|
||||||
|
|
||||||
|
-- STOCK_TRANSFER_OUT: if AYAM-specific row already exists, enforce desired value.
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
allow_overconsume = FALSE,
|
||||||
|
priority = 30,
|
||||||
|
is_active = TRUE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'STOCK_TRANSFER_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_transfer_block_ayam_only';
|
||||||
|
|
||||||
|
-- STOCK_TRANSFER_OUT: insert AYAM-specific if no suitable row exists.
|
||||||
|
INSERT INTO fifo_stock_v2_overconsume_rules(
|
||||||
|
flag_group_code,
|
||||||
|
function_code,
|
||||||
|
lane,
|
||||||
|
allow_overconsume,
|
||||||
|
priority,
|
||||||
|
reason,
|
||||||
|
is_active
|
||||||
|
)
|
||||||
|
SELECT 'AYAM', 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block_ayam_only', TRUE
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM fifo_stock_v2_overconsume_rules
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'STOCK_TRANSFER_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_transfer_block_ayam_only'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- STOCK_TRANSFER_OUT: deactivate remaining global rule (if any duplicate row exists).
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
is_active = FALSE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'STOCK_TRANSFER_OUT'
|
||||||
|
AND flag_group_code IS NULL
|
||||||
|
AND reason = 'fifo_v2_exception_transfer_block';
|
||||||
|
|
||||||
|
-- CHICKIN_OUT: enforce AYAM-specific hard-block (cannot pending).
|
||||||
|
UPDATE fifo_stock_v2_overconsume_rules
|
||||||
|
SET
|
||||||
|
allow_overconsume = FALSE,
|
||||||
|
priority = 25,
|
||||||
|
is_active = TRUE
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'CHICKIN_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_chickin_block_ayam_only';
|
||||||
|
|
||||||
|
INSERT INTO fifo_stock_v2_overconsume_rules(
|
||||||
|
flag_group_code,
|
||||||
|
function_code,
|
||||||
|
lane,
|
||||||
|
allow_overconsume,
|
||||||
|
priority,
|
||||||
|
reason,
|
||||||
|
is_active
|
||||||
|
)
|
||||||
|
SELECT 'AYAM', 'CHICKIN_OUT', 'USABLE', FALSE, 25, 'fifo_v2_exception_chickin_block_ayam_only', TRUE
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM fifo_stock_v2_overconsume_rules
|
||||||
|
WHERE lane = 'USABLE'
|
||||||
|
AND function_code = 'CHICKIN_OUT'
|
||||||
|
AND flag_group_code = 'AYAM'
|
||||||
|
AND reason = 'fifo_v2_exception_chickin_block_ayam_only'
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -38,9 +38,10 @@ const (
|
|||||||
P_ExpenseDocumentRealizations = "lti.expense.document.realization"
|
P_ExpenseDocumentRealizations = "lti.expense.document.realization"
|
||||||
)
|
)
|
||||||
const (
|
const (
|
||||||
P_AdjustmentGetAll = "lti.inventory.list"
|
P_AdjustmentGetAll = "lti.inventory.list"
|
||||||
P_AdjustmentCreate = "lti.inventory.create"
|
P_AdjustmentCreate = "lti.inventory.create"
|
||||||
P_AdjustmentGetOne = "lti.inventory.detail"
|
P_AdjustmentGetOne = "lti.inventory.detail"
|
||||||
|
P_AdjustmentDeleteOne = "lti.inventory.delete"
|
||||||
)
|
)
|
||||||
const (
|
const (
|
||||||
P_ApprovalGetAll = "lti.approval.list"
|
P_ApprovalGetAll = "lti.approval.list"
|
||||||
|
|||||||
@@ -103,3 +103,22 @@ func (u *AdjustmentController) GetOne(c *fiber.Ctx) error {
|
|||||||
Data: dto.ToAdjustmentDetailDTO(stockLog),
|
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 := v1.Group("/adjustments")
|
||||||
route.Use(m.Auth(u))
|
route.Use(m.Auth(u))
|
||||||
// Standard CRUD routes following master data pattern
|
// Standard CRUD routes following master data pattern
|
||||||
route.Get("/",m.RequirePermissions(m.P_AdjustmentGetAll), ctrl.AdjustmentHistory) // Get all with pagination and filters
|
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.Post("/", m.RequirePermissions(m.P_AdjustmentCreate), ctrl.Adjustment) // Create adjustment
|
||||||
route.Get("/:id",m.RequirePermissions(m.P_AdjustmentGetOne), ctrl.GetOne)
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"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"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AdjustmentService interface {
|
type AdjustmentService interface {
|
||||||
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error)
|
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error)
|
||||||
GetOne(ctx *fiber.Ctx, id uint) (*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)
|
AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +54,13 @@ const (
|
|||||||
flagGroupAyam = "AYAM"
|
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(
|
func NewAdjustmentService(
|
||||||
productRepo productRepo.ProductRepository,
|
productRepo productRepo.ProductRepository,
|
||||||
stockLogsRepo stockLogsRepo.StockLogRepository,
|
stockLogsRepo stockLogsRepo.StockLogRepository,
|
||||||
@@ -104,6 +114,172 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentSto
|
|||||||
return adjustmentStock, nil
|
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) {
|
func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) {
|
||||||
if err := s.Validate.Struct(req); err != nil {
|
if err := s.Validate.Struct(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -527,6 +703,118 @@ func (s *adjustmentService) resolveOverconsumePolicy(
|
|||||||
return *selected, nil
|
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) {
|
func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
|
||||||
warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil)
|
warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -593,6 +881,28 @@ func (s *adjustmentService) resolveAyamSourceProductWarehouse(
|
|||||||
return &sourcePW, nil
|
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(
|
func (s *adjustmentService) createAdjustmentStockLog(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
stockLogRepo stockLogsRepo.StockLogRepository,
|
stockLogRepo stockLogsRepo.StockLogRepository,
|
||||||
|
|||||||
+20
-21
@@ -13,7 +13,6 @@ import (
|
|||||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations"
|
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"
|
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"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -259,28 +258,28 @@ func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type usageRow struct {
|
type populationRemainingRow struct {
|
||||||
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
UsedQty float64 `gorm:"column:used_qty"`
|
RemainingQty float64 `gorm:"column:remaining_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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usageMap := make(map[uint]float64, len(usageRows))
|
var populationRows []populationRemainingRow
|
||||||
for _, row := range usageRows {
|
if err := s.Repository.DB().WithContext(c.Context()).
|
||||||
usageMap[row.ProductWarehouseID] = row.UsedQty
|
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))
|
filtered := make([]entity.ProductWarehouse, 0, len(rows))
|
||||||
@@ -291,7 +290,7 @@ func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
available := row.Quantity - usageMap[row.Id]
|
available := row.Quantity - populationRemainingByPW[row.Id]
|
||||||
if available < 0 {
|
if available < 0 {
|
||||||
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."
|
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 {
|
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{
|
return &transferService{
|
||||||
Log: utils.Log,
|
Log: utils.Log,
|
||||||
@@ -674,7 +681,7 @@ func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|||||||
for _, detail := range details {
|
for _, detail := range details {
|
||||||
detailIDs = append(detailIDs, detail.Id)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,37 +943,97 @@ func (s *transferService) ensureTransferAccess(ctx context.Context, id uint, c *
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *transferService) ensureNoDownstreamConsumptionForDelete(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error {
|
func (s *transferService) ensureDeletePolicyForDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error {
|
||||||
if len(detailIDs) == 0 {
|
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
|
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)
|
db := s.StockTransferRepo.DB().WithContext(ctx)
|
||||||
if tx != nil {
|
if tx != nil {
|
||||||
db = tx.WithContext(ctx)
|
db = tx.WithContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
type downstreamRow struct {
|
var rows []downstreamDependency
|
||||||
UsableType string `gorm:"column:usable_type"`
|
err := db.Table("stock_allocations").
|
||||||
UsableID uint64 `gorm:"column:usable_id"`
|
Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
|
||||||
}
|
|
||||||
|
|
||||||
var rows []downstreamRow
|
|
||||||
if err := db.Table("stock_allocations").
|
|
||||||
Select("usable_type, usable_id").
|
|
||||||
Where("stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
|
Where("stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
|
||||||
Where("stockable_id IN ?", detailIDs).
|
Where("stockable_id IN ?", detailIDs).
|
||||||
Where("status = ?", entity.StockAllocationStatusActive).
|
Where("status = ?", entity.StockAllocationStatusActive).
|
||||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||||
Where("deleted_at IS NULL").
|
Where("deleted_at IS NULL").
|
||||||
Group("usable_type, usable_id").
|
Group("usable_type, usable_id, function_code, flag_group_code").
|
||||||
Scan(&rows).Error; err != nil {
|
Scan(&rows).Error
|
||||||
s.Log.Errorf("Failed to validate downstream stock transfer consumption: %+v", err)
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock")
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDownstreamDependencySummary(rows []downstreamDependency) string {
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return nil
|
return "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencyMap := make(map[string]map[uint64]struct{})
|
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)))
|
details = append(details, fmt.Sprintf("%s=%s", label, joinUint64(ids)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fiber.NewError(
|
return strings.Join(details, ", ")
|
||||||
fiber.StatusBadRequest,
|
}
|
||||||
fmt.Sprintf("%s Dependensi aktif: %s.", transferDeleteDownstreamGuardMessage, 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 {
|
func mapTransferDownstreamUsableLabel(usableType string) string {
|
||||||
|
|||||||
@@ -646,24 +646,72 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows []downstreamRow
|
var rows []downstreamRow
|
||||||
if err := db.Table("stock_allocations sa").
|
dependencyTypes := []string{
|
||||||
Select("sa.usable_type, sa.usable_id").
|
fifo.UsableKeyMarketingDelivery.String(),
|
||||||
Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id").
|
fifo.UsableKeyRecordingStock.String(),
|
||||||
Where("pfp.project_chickin_id = ?", chickinID).
|
fifo.UsableKeyRecordingDepletion.String(),
|
||||||
Where("pfp.deleted_at IS NULL").
|
fifo.UsableKeyStockTransferOut.String(),
|
||||||
Where("sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
|
fifo.UsableKeyAdjustmentOut.String(),
|
||||||
Where("sa.status = ?", entity.StockAllocationStatusActive).
|
fifo.UsableKeyTransferToLayingOut.String(),
|
||||||
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
}
|
||||||
Where("sa.deleted_at IS NULL").
|
|
||||||
Where("sa.usable_type IN ?", []string{
|
query := `
|
||||||
fifo.UsableKeyMarketingDelivery.String(),
|
WITH chickin_sources AS (
|
||||||
fifo.UsableKeyRecordingDepletion.String(),
|
SELECT DISTINCT sa.stockable_type, sa.stockable_id
|
||||||
fifo.UsableKeyStockTransferOut.String(),
|
FROM stock_allocations sa
|
||||||
fifo.UsableKeyAdjustmentOut.String(),
|
WHERE sa.usable_type = ?
|
||||||
fifo.UsableKeyTransferToLayingOut.String(),
|
AND sa.usable_id = ?
|
||||||
}).
|
AND sa.status = ?
|
||||||
Group("sa.usable_type, sa.usable_id").
|
AND sa.allocation_purpose = ?
|
||||||
Scan(&rows).Error; err != nil {
|
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)
|
s.Log.Errorf("Failed to validate downstream consumption for chickin %d: %+v", chickinID, err)
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan chickin")
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan chickin")
|
||||||
}
|
}
|
||||||
@@ -682,7 +730,7 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
|
|||||||
switch row.UsableType {
|
switch row.UsableType {
|
||||||
case fifo.UsableKeyMarketingDelivery.String():
|
case fifo.UsableKeyMarketingDelivery.String():
|
||||||
marketingIDs[row.UsableID] = struct{}{}
|
marketingIDs[row.UsableID] = struct{}{}
|
||||||
case fifo.UsableKeyRecordingDepletion.String():
|
case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String():
|
||||||
recordingIDs[row.UsableID] = struct{}{}
|
recordingIDs[row.UsableID] = struct{}{}
|
||||||
case fifo.UsableKeyStockTransferOut.String():
|
case fifo.UsableKeyStockTransferOut.String():
|
||||||
transferIDs[row.UsableID] = struct{}{}
|
transferIDs[row.UsableID] = struct{}{}
|
||||||
|
|||||||
@@ -522,9 +522,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
|||||||
}
|
}
|
||||||
|
|
||||||
recordingEntity = recording
|
recordingEntity = recording
|
||||||
if err := s.ensurePopulationMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pfkForRoute := recordingEntity.ProjectFlockKandang
|
pfkForRoute := recordingEntity.ProjectFlockKandang
|
||||||
if pfkForRoute == nil || pfkForRoute.Id == 0 {
|
if pfkForRoute == nil || pfkForRoute.Id == 0 {
|
||||||
fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId)
|
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
|
pfkForRoute = fetchedPfk
|
||||||
}
|
}
|
||||||
routePayload := buildRecordingRoutePayloadFromUpdate(req, recordingEntity)
|
routePayload := buildRecordingRoutePayloadFromUpdate(req)
|
||||||
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
|
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -594,6 +591,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
|||||||
if match {
|
if match {
|
||||||
hasDepletionChanges = false
|
hasDepletionChanges = false
|
||||||
} else {
|
} else {
|
||||||
|
if err := s.ensurePopulationMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := s.ensureDepletionMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
|
if err := s.ensureDepletionMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
|
||||||
return err
|
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)
|
s.Log.Errorf("Failed to find recording: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.ensurePopulationMutationAllowed(ctx, recording, "hapus"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
existingDepletions, err := s.Repository.ListDepletions(tx, recording.Id)
|
existingDepletions, err := s.Repository.ListDepletions(tx, recording.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Log.Errorf("Failed to list existing depletions: %+v", err)
|
s.Log.Errorf("Failed to list existing depletions: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(existingDepletions) > 0 {
|
if len(existingDepletions) > 0 {
|
||||||
|
if err := s.ensurePopulationMutationAllowed(ctx, recording, "hapus"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := s.ensureDepletionMutationAllowed(ctx, recording, "hapus"); err != nil {
|
if err := s.ensureDepletionMutationAllowed(ctx, recording, "hapus"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1376,60 +1376,34 @@ func buildRecordingRoutePayloadFromCreate(req *validation.Create) recordingRoute
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildRecordingRoutePayloadFromUpdate(req *validation.Update, existing *entity.Recording) recordingRoutePayload {
|
func buildRecordingRoutePayloadFromUpdate(req *validation.Update) recordingRoutePayload {
|
||||||
payload := recordingRoutePayload{}
|
payload := recordingRoutePayload{}
|
||||||
if req == nil && existing == nil {
|
if req == nil {
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
if req != nil && req.Stocks != nil {
|
if req.Stocks != nil {
|
||||||
for _, stock := range req.Stocks {
|
for _, stock := range req.Stocks {
|
||||||
if stock.Qty > 0 {
|
if stock.Qty > 0 {
|
||||||
payload.StockCount++
|
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 {
|
for _, depletion := range req.Depletions {
|
||||||
if depletion.Qty > 0 {
|
if depletion.Qty > 0 {
|
||||||
payload.DepletionCount++
|
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 {
|
for _, egg := range req.Eggs {
|
||||||
if egg.Qty > 0 {
|
if egg.Qty > 0 {
|
||||||
payload.EggCount++
|
payload.EggCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if existing != nil {
|
|
||||||
for _, egg := range existing.Eggs {
|
|
||||||
if egg.Qty > 0 {
|
|
||||||
payload.EggCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PurchaseService interface {
|
type PurchaseService interface {
|
||||||
@@ -67,6 +69,13 @@ type staffAdjustmentPayload struct {
|
|||||||
NewItems []*entity.PurchaseItem
|
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(
|
func NewPurchaseService(
|
||||||
validate *validator.Validate,
|
validate *validator.Validate,
|
||||||
purchaseRepo rPurchase.PurchaseRepository,
|
purchaseRepo rPurchase.PurchaseRepository,
|
||||||
@@ -884,8 +893,28 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
if receivedQty > item.SubQty {
|
if receivedQty > item.SubQty {
|
||||||
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, 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) {
|
if receivedQty < item.TotalUsed {
|
||||||
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, 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 {
|
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 {
|
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
repoTx := rPurchase.NewPurchaseRepository(tx)
|
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 {
|
if err := repoTx.DeleteItems(ctx, purchase.Id, toDelete); err != nil {
|
||||||
return err
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(lockedIDs) > 0 {
|
if len(deps) > 0 && !allowPending {
|
||||||
return utils.BadRequest("Purchase already chickin, failed to delete purchase")
|
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 {
|
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
|
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 {
|
func isReceivingBelowUsedBlocked(item *entity.PurchaseItem, lockedIDs map[uint]struct{}) bool {
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user