Compare commits

...

19 Commits

Author SHA1 Message Date
Adnan Zahir c4c414aa94 Merge branch 'feat/BE/implement-new-trf' into 'dev/fifo-v2'
Fix transfer to laying delete and fix chikin delete with response recording

See merge request mbugroup/lti-api!366
2026-03-17 11:04:04 +07:00
ragilap c9dee7d1c4 add paired adjustment triger depletion adjustment 2026-03-17 11:02:37 +07:00
ragilap 131949874a changes name response transfer 2026-03-16 11:08:37 +07:00
ragilap d0f3392738 changes name response transfer 2026-03-16 10:41:43 +07:00
ragilap b2e70fa6eb add restrict create/edit/delete depletion 2026-03-14 15:39:09 +07:00
ragilap 5ba10113c3 add restrict create/edit/delete depletion 2026-03-14 15:38:47 +07:00
ragilap 29956528e5 fixing filter pw for transfer, add transfer delete 2026-03-13 11:22:10 +07:00
ragilap 9dcccabc6a fixing filter product warehouse transfer, cannot take from population 2026-03-11 15:10:49 +07:00
ragilap 333cb9e136 Fix logic recording transition 2026-03-10 17:02:45 +07:00
ragilap 3a8cc47fa0 Fix transfer to laying delete and fix chikin delete with response recording 2026-03-09 13:10:06 +07:00
Adnan Zahir c5a27ef3a6 Merge branch 'Feat/BE/implement-new-trf' into 'dev/fifo-v2'
fix: reimplement transfer to laying logics separating effective financial date...

See merge request mbugroup/lti-api!361
2026-03-08 23:32:31 +07:00
ragilap 45cc057dd4 Fix adjusment stock chickin, transfer to laying and chickin 2026-03-08 23:31:04 +07:00
Hafizh A. Y. b8fa79a2ab Merge branch 'fix/migration-fifo-v2' into 'dev/fifo-v2'
fix: migration fifo v2

See merge request mbugroup/lti-api!360
2026-03-08 12:02:11 +00:00
Hafizh A. Y b1c829bbf8 fix: migration fifo v2 2026-03-08 19:01:09 +07:00
ragilap fca96df3d9 Merge branch 'dev/fifo-v2' of https://gitlab.com/mbugroup/lti-api into dev/fifo-v2 2026-03-06 11:46:31 +07:00
Hafizh A. Y. 073b098843 Merge branch 'fix/chickin-master-product' into 'dev/fifo-v2'
fix: master product and chickin

See merge request mbugroup/lti-api!358
2026-03-06 03:26:18 +00:00
Hafizh A. Y 650f8e0fdb fix: master product and chickin 2026-03-06 10:24:43 +07:00
Adnan Zahir 7c6a401ac2 Merge branch 'fix/chickin-master-product' into 'dev/fifo-v2'
feat: refactor module adjusment stock, adjust constant, adjust table migration...

See merge request mbugroup/lti-api!357
2026-03-05 17:33:26 +07:00
Hafizh A. Y 345fe32433 fix: flag master data product and fix chickin approve 2026-03-05 17:18:19 +07:00
79 changed files with 6641 additions and 1130 deletions
@@ -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
}
@@ -141,6 +141,9 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
if remaining <= 0 { if remaining <= 0 {
break break
} }
if shouldSkipStockableForUsable(req, lot.Ref.LegacyTypeKey) {
continue
}
if lot.AvailableQuantity <= 0 { if lot.AvailableQuantity <= 0 {
continue continue
} }
@@ -207,6 +210,23 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
return result, nil return result, nil
} }
func shouldSkipStockableForUsable(req AllocateRequest, stockableType string) bool {
usableType := strings.ToUpper(strings.TrimSpace(req.Usable.LegacyTypeKey))
functionCode := strings.ToUpper(strings.TrimSpace(req.Usable.FunctionCode))
stockable := strings.ToUpper(strings.TrimSpace(stockableType))
// CHICKIN_OUT must consume physical stock sources, not population lots,
// otherwise approved chickin can consume its own just-created population.
if (usableType == "PROJECT_CHICKIN" || functionCode == "CHICKIN_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
return true
}
if (usableType == "STOCK_TRANSFER_OUT" || functionCode == "STOCK_TRANSFER_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
return true
}
return false
}
func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) { func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) {
if err := s.validateRollbackRequest(req); err != nil { if err := s.validateRollbackRequest(req); err != nil {
return nil, err return nil, err
@@ -479,10 +499,6 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
if len(rollbackRes.Details) > 0 { if len(rollbackRes.Details) > 0 {
result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...) result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...)
} }
minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity
if desiredQty < minDesired {
desiredQty = minDesired
}
if desiredQty <= 0 { if desiredQty <= 0 {
continue continue
@@ -685,16 +701,17 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
FlagGroupCode string `gorm:"column:flag_group_code"` FlagGroupCode string `gorm:"column:flag_group_code"`
} }
var latest row var latest row
err := tx.WithContext(ctx). latestQuery := tx.WithContext(ctx).
Table("stock_allocations"). Table("stock_allocations").
Select("flag_group_code"). Select("flag_group_code").
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID). Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
Where("engine_version = 'v2'"). Where("engine_version = 'v2'").
Where("allocation_purpose = ?", defaultAllocationPurpose()). Where("allocation_purpose = ?", defaultAllocationPurpose()).
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''"). Where("flag_group_code IS NOT NULL AND flag_group_code <> ''")
Order("id DESC"). if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
Limit(1). latestQuery = latestQuery.Where("function_code = ?", code)
Take(&latest).Error }
err := latestQuery.Order("id DESC").Limit(1).Take(&latest).Error
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" { if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
return latest.FlagGroupCode, nil return latest.FlagGroupCode, nil
} }
@@ -702,19 +719,56 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
return "", err return "", err
} }
var rules []routeRule rulesQuery := tx.WithContext(ctx).
err = tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules"). Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE"). Where("is_active = TRUE").
Where("lane = ?", string(LaneUsable)). Where("lane = ?", string(LaneUsable)).
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey). Where("legacy_type_key = ?", req.Usable.LegacyTypeKey)
Find(&rules).Error if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
rulesQuery = rulesQuery.Where("function_code = ?", code)
}
var rules []routeRule
err = rulesQuery.Find(&rules).Error
if err != nil { if err != nil {
return "", err return "", err
} }
if len(rules) == 0 { if len(rules) == 0 {
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey) return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey)
} }
if len(rules) > 1 && req.ProductWarehouseID != 0 {
type candidateRow struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var candidates []candidateRow
byProductQuery := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("DISTINCT rr.flag_group_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.lane = ?", string(LaneUsable)).
Where("rr.legacy_type_key = ?", req.Usable.LegacyTypeKey).
Where(`
EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = ?
AND f.flagable_type = 'products'
AND fm.flag_group_code = rr.flag_group_code
)
`, req.ProductWarehouseID)
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
byProductQuery = byProductQuery.Where("rr.function_code = ?", code)
}
if err := byProductQuery.Order("rr.flag_group_code ASC").Scan(&candidates).Error; err != nil {
return "", err
}
if len(candidates) == 1 {
return strings.TrimSpace(candidates[0].FlagGroupCode), nil
}
}
if len(rules) > 1 { if len(rules) > 1 {
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey) return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
} }
+4
View File
@@ -259,6 +259,10 @@ func defaultString(v, def string) string {
return v return v
} }
func LayingWeekStart() int {
return TransferToLayingGrowingMaxWeek
}
func joinPath(parts ...string) string { func joinPath(parts ...string) string {
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))
for _, part := range parts { for _, part := range parts {
@@ -0,0 +1,24 @@
BEGIN;
DROP INDEX IF EXISTS idx_stock_allocations_idempotency;
DROP INDEX IF EXISTS idx_stock_allocations_flag_group;
DROP INDEX IF EXISTS idx_stock_allocations_engine_version;
ALTER TABLE stock_allocations
DROP COLUMN IF EXISTS idempotency_key,
DROP COLUMN IF EXISTS reflow_run_id,
DROP COLUMN IF EXISTS function_code,
DROP COLUMN IF EXISTS flag_group_code,
DROP COLUMN IF EXISTS engine_version;
DROP TABLE IF EXISTS fifo_stock_v2_shadow_allocations;
DROP TABLE IF EXISTS fifo_stock_v2_reflow_checkpoints;
DROP TABLE IF EXISTS fifo_stock_v2_reflow_runs;
DROP TABLE IF EXISTS fifo_stock_v2_operation_log;
DROP TABLE IF EXISTS fifo_stock_v2_overconsume_rules;
DROP TABLE IF EXISTS fifo_stock_v2_route_rules;
DROP TABLE IF EXISTS fifo_stock_v2_traits;
DROP TABLE IF EXISTS fifo_stock_v2_flag_members;
DROP TABLE IF EXISTS fifo_stock_v2_flag_groups;
COMMIT;
@@ -0,0 +1,154 @@
BEGIN;
-- Bootstrap FIFO v2 core tables before seed migration (20260218090010).
-- Keep definitions aligned with 20260304033546_create_fifo_stock_v2_core.
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_groups (
code VARCHAR(64) PRIMARY KEY,
name VARCHAR(128) NOT NULL,
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_members (
flag_name VARCHAR(64) PRIMARY KEY,
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_traits (
id BIGSERIAL PRIMARY KEY,
source_table VARCHAR(64) NOT NULL,
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
date_table VARCHAR(64) NULL,
date_join_left_col VARCHAR(64) NULL,
date_join_right_col VARCHAR(64) NULL,
date_column VARCHAR(64) NOT NULL,
fallback_date_column VARCHAR(64) NULL,
sort_priority INT NOT NULL DEFAULT 100,
id_column VARCHAR(64) NOT NULL DEFAULT 'id',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (source_table, lane)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_route_rules (
id BIGSERIAL PRIMARY KEY,
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
function_code VARCHAR(64) NOT NULL,
source_table VARCHAR(64) NOT NULL,
source_id_column VARCHAR(64) NOT NULL DEFAULT 'id',
product_warehouse_col VARCHAR(64) NOT NULL,
quantity_col VARCHAR(64) NOT NULL,
used_quantity_col VARCHAR(64) NULL,
pending_quantity_col VARCHAR(64) NULL,
scope_sql TEXT NULL,
legacy_type_key VARCHAR(100) NOT NULL,
allow_pending_default BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (flag_group_code, lane, function_code, source_table)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_overconsume_rules (
id BIGSERIAL PRIMARY KEY,
flag_group_code VARCHAR(64) NULL REFERENCES fifo_stock_v2_flag_groups(code),
function_code VARCHAR(64) NULL,
lane VARCHAR(16) NOT NULL DEFAULT 'USABLE' CHECK (lane IN ('STOCKABLE', 'USABLE')),
allow_overconsume BOOLEAN NOT NULL,
priority INT NOT NULL DEFAULT 100,
reason TEXT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_operation_log (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(128) NOT NULL,
operation VARCHAR(16) NOT NULL CHECK (operation IN ('ALLOCATE', 'ROLLBACK', 'REFLOW', 'RECALCULATE')),
product_warehouse_id BIGINT NOT NULL,
flag_group_code VARCHAR(64) NOT NULL,
usable_type VARCHAR(100) NULL,
usable_id BIGINT NULL,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'DONE', 'FAILED')),
result_payload JSONB NULL,
error_text TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ NULL,
UNIQUE (idempotency_key, operation)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_runs (
id BIGSERIAL PRIMARY KEY,
mode VARCHAR(16) NOT NULL CHECK (mode IN ('DRY_RUN', 'APPLY')),
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'PAUSED', 'DONE', 'FAILED', 'CANCELLED')),
as_of TIMESTAMPTZ NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ NULL,
total_shards INT NOT NULL DEFAULT 0,
processed_shards INT NOT NULL DEFAULT 0,
processed_rows BIGINT NOT NULL DEFAULT 0,
mismatch_rows BIGINT NOT NULL DEFAULT 0,
created_by BIGINT NULL,
note TEXT NULL
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_checkpoints (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
flag_group_code VARCHAR(64) NOT NULL,
product_warehouse_id BIGINT NOT NULL,
last_sort_at TIMESTAMPTZ NULL,
last_source_table VARCHAR(64) NULL,
last_source_id BIGINT NULL,
status VARCHAR(16) NOT NULL CHECK (status IN ('PENDING', 'RUNNING', 'DONE', 'FAILED')) DEFAULT 'PENDING',
retry_count INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (run_id, flag_group_code, product_warehouse_id)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_shadow_allocations (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
product_warehouse_id BIGINT NOT NULL,
stockable_type VARCHAR(100) NOT NULL,
stockable_id BIGINT NOT NULL,
usable_type VARCHAR(100) NOT NULL,
usable_id BIGINT NOT NULL,
qty NUMERIC(15,3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
sort_at TIMESTAMPTZ NULL,
source_table VARCHAR(64) NULL,
source_id BIGINT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_usable
ON fifo_stock_v2_shadow_allocations(run_id, usable_type, usable_id);
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_stockable
ON fifo_stock_v2_shadow_allocations(run_id, stockable_type, stockable_id);
ALTER TABLE stock_allocations
ADD COLUMN IF NOT EXISTS engine_version VARCHAR(8) NOT NULL DEFAULT 'v1',
ADD COLUMN IF NOT EXISTS flag_group_code VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS function_code VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS reflow_run_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128) NULL;
CREATE INDEX IF NOT EXISTS idx_stock_allocations_engine_version
ON stock_allocations(engine_version);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_flag_group
ON stock_allocations(flag_group_code);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_idempotency
ON stock_allocations(idempotency_key);
COMMIT;
@@ -1,37 +1,60 @@
BEGIN; BEGIN;
DELETE FROM fifo_stock_v2_overconsume_rules DO $$
WHERE reason IN ( BEGIN
'fifo_v2_default_allow', IF to_regclass('public.fifo_stock_v2_overconsume_rules') IS NOT NULL THEN
'fifo_v2_exception_ayam_depletion_block', EXECUTE '
'fifo_v2_exception_marketing_block', DELETE FROM fifo_stock_v2_overconsume_rules
'fifo_v2_exception_transfer_block', WHERE reason IN (
'fifo_v2_exception_adjustment_block', ''fifo_v2_default_allow'',
'fifo_v2_exception_transfer_laying_block' ''fifo_v2_exception_ayam_depletion_block'',
); ''fifo_v2_exception_marketing_block'',
''fifo_v2_exception_transfer_block'',
''fifo_v2_exception_adjustment_block'',
''fifo_v2_exception_transfer_laying_block''
)
';
END IF;
DELETE FROM fifo_stock_v2_route_rules IF to_regclass('public.fifo_stock_v2_route_rules') IS NOT NULL THEN
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE'); EXECUTE '
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
DELETE FROM fifo_stock_v2_traits IF to_regclass('public.fifo_stock_v2_traits') IS NOT NULL THEN
WHERE source_table IN ( EXECUTE '
'purchase_items', DELETE FROM fifo_stock_v2_traits
'stock_transfer_details', WHERE source_table IN (
'laying_transfer_targets', ''purchase_items'',
'laying_transfer_sources', ''stock_transfer_details'',
'adjustment_stocks', ''laying_transfer_targets'',
'recording_stocks', ''laying_transfer_sources'',
'recording_depletions', ''adjustment_stocks'',
'recording_eggs', ''recording_stocks'',
'marketing_delivery_products', ''recording_depletions'',
'project_chickins', ''recording_eggs'',
'project_flock_populations' ''marketing_delivery_products'',
); ''project_chickins'',
''project_flock_populations''
)
';
END IF;
DELETE FROM fifo_stock_v2_flag_members IF to_regclass('public.fifo_stock_v2_flag_members') IS NOT NULL THEN
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE'); EXECUTE '
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_group_code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
DELETE FROM fifo_stock_v2_flag_groups IF to_regclass('public.fifo_stock_v2_flag_groups') IS NOT NULL THEN
WHERE code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE'); EXECUTE '
DELETE FROM fifo_stock_v2_flag_groups
WHERE code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
END $$;
COMMIT; COMMIT;
@@ -2,12 +2,19 @@ BEGIN;
-- Restore CHICKIN route if rollback is required. -- Restore CHICKIN route if rollback is required.
-- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration. -- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration.
UPDATE fifo_stock_v2_route_rules DO $$
SET is_active = TRUE, BEGIN
updated_at = NOW() IF to_regclass('public.fifo_stock_v2_route_rules') IS NOT NULL THEN
WHERE flag_group_code = 'AYAM' EXECUTE '
AND lane = 'USABLE' UPDATE fifo_stock_v2_route_rules
AND function_code = 'CHICKIN_OUT' SET is_active = TRUE,
AND source_table = 'project_chickins'; updated_at = NOW()
WHERE flag_group_code = ''AYAM''
AND lane = ''USABLE''
AND function_code = ''CHICKIN_OUT''
AND source_table = ''project_chickins''
';
END IF;
END $$;
COMMIT; COMMIT;
@@ -0,0 +1,7 @@
BEGIN;
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_name = 'AYAM'
AND flag_group_code = 'AYAM';
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority, is_active, created_at, updated_at)
VALUES
('AYAM', 'AYAM', 5, TRUE, NOW(), NOW())
ON CONFLICT (flag_name) DO UPDATE
SET
flag_group_code = EXCLUDED.flag_group_code,
priority = EXCLUDED.priority,
is_active = TRUE,
updated_at = NOW();
COMMIT;
@@ -0,0 +1,60 @@
BEGIN;
UPDATE fifo_stock_v2_route_rules
SET
is_active = TRUE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND source_table = 'laying_transfer_sources';
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND source_table = 'laying_transfers';
UPDATE fifo_stock_v2_traits
SET is_active = TRUE
WHERE source_table = 'laying_transfer_sources'
AND lane = 'USABLE';
UPDATE fifo_stock_v2_traits
SET is_active = FALSE
WHERE source_table = 'laying_transfers'
AND lane = 'USABLE';
DROP INDEX IF EXISTS idx_laying_transfers_source_project_flock_kandang_id;
DROP INDEX IF EXISTS idx_laying_transfers_source_product_warehouse_id;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_laying_transfers_source_project_flock_kandang_id'
) THEN
ALTER TABLE laying_transfers
DROP CONSTRAINT fk_laying_transfers_source_project_flock_kandang_id;
END IF;
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_laying_transfers_source_product_warehouse_id'
) THEN
ALTER TABLE laying_transfers
DROP CONSTRAINT fk_laying_transfers_source_product_warehouse_id;
END IF;
END $$;
ALTER TABLE laying_transfers
DROP COLUMN IF EXISTS source_project_flock_kandang_id,
DROP COLUMN IF EXISTS source_product_warehouse_id,
DROP COLUMN IF EXISTS source_requested_qty,
DROP COLUMN IF EXISTS source_usage_qty,
DROP COLUMN IF EXISTS source_pending_usage_qty;
COMMIT;
@@ -0,0 +1,170 @@
BEGIN;
ALTER TABLE laying_transfers
ADD COLUMN IF NOT EXISTS source_project_flock_kandang_id BIGINT,
ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT,
ADD COLUMN IF NOT EXISTS source_requested_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS source_usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS source_pending_usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_laying_transfers_source_project_flock_kandang_id'
) THEN
ALTER TABLE laying_transfers
ADD CONSTRAINT fk_laying_transfers_source_project_flock_kandang_id
FOREIGN KEY (source_project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_laying_transfers_source_product_warehouse_id'
) THEN
ALTER TABLE laying_transfers
ADD CONSTRAINT fk_laying_transfers_source_product_warehouse_id
FOREIGN KEY (source_product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_laying_transfers_source_project_flock_kandang_id
ON laying_transfers(source_project_flock_kandang_id);
CREATE INDEX IF NOT EXISTS idx_laying_transfers_source_product_warehouse_id
ON laying_transfers(source_product_warehouse_id);
WITH single_source AS (
SELECT
lts.laying_transfer_id,
MIN(lts.source_project_flock_kandang_id) AS source_project_flock_kandang_id,
MIN(lts.product_warehouse_id) AS source_product_warehouse_id
FROM laying_transfer_sources lts
WHERE lts.deleted_at IS NULL
GROUP BY lts.laying_transfer_id
HAVING COUNT(*) = 1
)
UPDATE laying_transfers lt
SET
source_project_flock_kandang_id = ss.source_project_flock_kandang_id,
source_product_warehouse_id = ss.source_product_warehouse_id
FROM single_source ss
WHERE lt.id = ss.laying_transfer_id
AND (lt.source_project_flock_kandang_id IS NULL OR lt.source_project_flock_kandang_id = 0);
WITH source_totals AS (
SELECT
laying_transfer_id,
COALESCE(SUM(requested_qty), 0) AS requested_qty,
COALESCE(SUM(usage_qty), 0) AS usage_qty,
COALESCE(SUM(pending_usage_qty), 0) AS pending_qty
FROM laying_transfer_sources
WHERE deleted_at IS NULL
GROUP BY laying_transfer_id
)
UPDATE laying_transfers lt
SET
source_requested_qty = CASE
WHEN lt.source_requested_qty = 0 THEN st.requested_qty
ELSE lt.source_requested_qty
END,
source_usage_qty = CASE
WHEN lt.source_usage_qty = 0 THEN st.usage_qty
ELSE lt.source_usage_qty
END,
source_pending_usage_qty = CASE
WHEN lt.source_pending_usage_qty = 0 THEN st.pending_qty
ELSE lt.source_pending_usage_qty
END
FROM source_totals st
WHERE lt.id = st.laying_transfer_id;
WITH target_totals AS (
SELECT
laying_transfer_id,
COALESCE(SUM(total_qty), 0) AS total_qty
FROM laying_transfer_targets
WHERE deleted_at IS NULL
GROUP BY laying_transfer_id
)
UPDATE laying_transfers lt
SET source_requested_qty = tt.total_qty
FROM target_totals tt
WHERE lt.id = tt.laying_transfer_id
AND (lt.source_requested_qty IS NULL OR lt.source_requested_qty = 0);
INSERT INTO fifo_stock_v2_traits(
source_table,
lane,
date_table,
date_join_left_col,
date_join_right_col,
date_column,
fallback_date_column,
sort_priority,
id_column
)
VALUES
('laying_transfers', 'USABLE', NULL, NULL, NULL, 'transfer_date', NULL, 25, 'id')
ON CONFLICT (source_table, lane) DO UPDATE
SET
date_table = EXCLUDED.date_table,
date_join_left_col = EXCLUDED.date_join_left_col,
date_join_right_col = EXCLUDED.date_join_right_col,
date_column = EXCLUDED.date_column,
fallback_date_column = EXCLUDED.fallback_date_column,
sort_priority = EXCLUDED.sort_priority,
id_column = EXCLUDED.id_column,
is_active = TRUE;
UPDATE fifo_stock_v2_traits
SET is_active = FALSE
WHERE source_table = 'laying_transfer_sources'
AND lane = 'USABLE';
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfers', 'id', 'source_product_warehouse_id', 'source_usage_qty', NULL, 'source_pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = TRUE,
updated_at = NOW();
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND source_table = 'laying_transfer_sources';
COMMIT;
@@ -0,0 +1,18 @@
BEGIN;
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'STOCKABLE'
AND function_code = 'POPULATION_IN'
AND source_table = 'project_flock_populations'
AND legacy_type_key = 'PROJECT_FLOCK_POPULATION';
UPDATE fifo_stock_v2_traits
SET is_active = FALSE
WHERE source_table = 'project_flock_populations'
AND lane = 'STOCKABLE';
COMMIT;
@@ -0,0 +1,81 @@
BEGIN;
INSERT INTO fifo_stock_v2_traits(
source_table,
lane,
date_table,
date_join_left_col,
date_join_right_col,
date_column,
fallback_date_column,
sort_priority,
id_column,
is_active
)
VALUES
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id', TRUE)
ON CONFLICT (source_table, lane) DO UPDATE
SET
date_table = EXCLUDED.date_table,
date_join_left_col = EXCLUDED.date_join_left_col,
date_join_right_col = EXCLUDED.date_join_right_col,
date_column = EXCLUDED.date_column,
fallback_date_column = EXCLUDED.fallback_date_column,
sort_priority = EXCLUDED.sort_priority,
id_column = EXCLUDED.id_column,
is_active = TRUE;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = TRUE,
updated_at = NOW();
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id;
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.stockable_id = p.id
);
COMMIT;
@@ -0,0 +1,12 @@
BEGIN;
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT;
@@ -0,0 +1,33 @@
BEGIN;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = TRUE,
updated_at = NOW();
COMMIT;
@@ -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;
@@ -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;
@@ -0,0 +1,14 @@
BEGIN;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS chk_adjustment_stocks_paired_not_self;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_paired_adjustment_id;
DROP INDEX IF EXISTS idx_adjustment_stocks_paired_adjustment_id;
ALTER TABLE adjustment_stocks
DROP COLUMN IF EXISTS paired_adjustment_id;
COMMIT;
@@ -0,0 +1,86 @@
BEGIN;
ALTER TABLE adjustment_stocks
ADD COLUMN IF NOT EXISTS paired_adjustment_id BIGINT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_adjustment_stocks_paired_adjustment_id'
) THEN
ALTER TABLE adjustment_stocks
ADD CONSTRAINT fk_adjustment_stocks_paired_adjustment_id
FOREIGN KEY (paired_adjustment_id)
REFERENCES adjustment_stocks(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS chk_adjustment_stocks_paired_not_self;
ALTER TABLE adjustment_stocks
ADD CONSTRAINT chk_adjustment_stocks_paired_not_self
CHECK (paired_adjustment_id IS NULL OR paired_adjustment_id <> id);
CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_paired_adjustment_id
ON adjustment_stocks(paired_adjustment_id);
-- Backfill pairing untuk depletion-out <-> depletion-in existing records.
WITH candidates AS (
SELECT
src.id AS src_id,
dst.id AS dst_id,
ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) AS ts_diff,
ABS(dst.id - src.id) AS id_diff,
ROW_NUMBER() OVER (
PARTITION BY src.id
ORDER BY ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) ASC,
ABS(dst.id - src.id) ASC,
dst.id ASC
) AS rn_src,
ROW_NUMBER() OVER (
PARTITION BY dst.id
ORDER BY ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) ASC,
ABS(dst.id - src.id) ASC,
src.id ASC
) AS rn_dst
FROM adjustment_stocks src
JOIN adjustment_stocks dst
ON dst.id <> src.id
AND dst.transaction_type = src.transaction_type
AND dst.function_code = 'RECORDING_DEPLETION_IN'
AND src.function_code = 'RECORDING_DEPLETION_OUT'
AND dst.paired_adjustment_id IS NULL
AND src.paired_adjustment_id IS NULL
AND ABS((COALESCE(src.usage_qty, 0) + COALESCE(src.pending_qty, 0)) - COALESCE(dst.total_qty, 0)) < 0.0001
AND COALESCE(src.price, 0) = COALESCE(dst.price, 0)
AND COALESCE(src.grand_total, 0) = COALESCE(dst.grand_total, 0)
AND ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) <= 120
),
chosen AS (
SELECT src_id, dst_id
FROM candidates
WHERE rn_src = 1
AND rn_dst = 1
)
UPDATE adjustment_stocks src
SET paired_adjustment_id = c.dst_id
FROM chosen c
WHERE src.id = c.src_id
AND src.paired_adjustment_id IS NULL;
WITH chosen AS (
SELECT a.id AS src_id, a.paired_adjustment_id AS dst_id
FROM adjustment_stocks a
WHERE a.function_code = 'RECORDING_DEPLETION_OUT'
AND a.paired_adjustment_id IS NOT NULL
)
UPDATE adjustment_stocks dst
SET paired_adjustment_id = c.src_id
FROM chosen c
WHERE dst.id = c.dst_id
AND dst.paired_adjustment_id IS NULL;
COMMIT;
+2
View File
@@ -5,6 +5,7 @@ import "time"
type AdjustmentStock struct { type AdjustmentStock struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
PairedAdjustmentId *uint `gorm:"column:paired_adjustment_id"`
TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"` TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"`
FunctionCode string `gorm:"column:function_code;type:varchar(64)"` FunctionCode string `gorm:"column:function_code;type:varchar(64)"`
TotalQty float64 `gorm:"column:total_qty;default:0"` TotalQty float64 `gorm:"column:total_qty;default:0"`
@@ -18,5 +19,6 @@ type AdjustmentStock struct {
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"` AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
PairedAdjustment *AdjustmentStock `gorm:"foreignKey:PairedAdjustmentId;references:Id"`
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"` StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
} }
+28 -21
View File
@@ -7,26 +7,33 @@ import (
) )
type LayingTransfer struct { type LayingTransfer struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
TransferNumber string `gorm:"uniqueIndex;not null"` TransferNumber string `gorm:"uniqueIndex;not null"`
FromProjectFlockId uint `gorm:"not null"` FromProjectFlockId uint `gorm:"not null"`
ToProjectFlockId uint `gorm:"not null"` ToProjectFlockId uint `gorm:"not null"`
TransferDate time.Time `gorm:"type:date;not null"` SourceProjectFlockKandangId *uint `gorm:"index"`
EconomicCutoffDate *time.Time `gorm:"type:date"` SourceProductWarehouseId *uint `gorm:"index"`
EffectiveMoveDate *time.Time `gorm:"type:date"` SourceRequestedQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
ExecutedAt *time.Time `gorm:"type:timestamptz"` SourceUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
ExecutedBy *uint `gorm:"index"` SourcePendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
Notes string `gorm:"type:text"` TransferDate time.Time `gorm:"type:date;not null"`
CreatedBy uint `gorm:"not null"` EconomicCutoffDate *time.Time `gorm:"type:date"`
CreatedAt time.Time `gorm:"autoCreateTime"` EffectiveMoveDate *time.Time `gorm:"type:date"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` ExecutedAt *time.Time `gorm:"type:timestamptz"`
DeletedAt gorm.DeletedAt `gorm:"index"` ExecutedBy *uint `gorm:"index"`
Notes string `gorm:"type:text"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"` FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"` ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"`
ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"` SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseId;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"`
LatestApproval *Approval `gorm:"-" json:"-"` Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"`
} }
+6 -5
View File
@@ -1,11 +1,12 @@
package entities package entities
type ProductWarehouse struct { type ProductWarehouse struct {
Id uint `gorm:"primaryKey;column:id"` Id uint `gorm:"primaryKey;column:id"`
ProductId uint `gorm:"column:product_id;not null"` ProductId uint `gorm:"column:product_id;not null"`
WarehouseId uint `gorm:"column:warehouse_id;not null"` WarehouseId uint `gorm:"column:warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
AvailableQty *float64 `gorm:"-"`
// Relations // Relations
Product Product `gorm:"foreignKey:ProductId;references:Id"` Product Product `gorm:"foreignKey:ProductId;references:Id"`
+4
View File
@@ -43,4 +43,8 @@ type Recording struct {
StandardEggMass *float64 `gorm:"-"` StandardEggMass *float64 `gorm:"-"`
StandardEggWeight *float64 `gorm:"-"` StandardEggWeight *float64 `gorm:"-"`
StandardFcr *float64 `gorm:"-"` StandardFcr *float64 `gorm:"-"`
PopulationCanChange *bool `gorm:"-"`
TransferExecuted *bool `gorm:"-"`
IsTransition *bool `gorm:"-"`
IsLaying *bool `gorm:"-"`
} }
+5 -3
View File
@@ -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"
@@ -70,6 +71,7 @@ const (
P_TransferGetAll = "lti.inventory.transfer.list" P_TransferGetAll = "lti.inventory.transfer.list"
P_TransferGetOne = "lti.inventory.transfer.detail" P_TransferGetOne = "lti.inventory.transfer.detail"
P_TransferCreateOne = "lti.inventory.transfer.create" P_TransferCreateOne = "lti.inventory.transfer.create"
P_TransferDeleteOne = "lti.inventory.transfer.delete"
) )
const ( const (
@@ -1291,8 +1291,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
COALESCE(p.product_price, 0) AS price COALESCE(p.product_price, 0) AS price
`). `).
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = lt.id"). Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lt.source_product_warehouse_id").
Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lts.product_warehouse_id").
Joins("LEFT JOIN warehouses w_source ON w_source.id = pw_source.warehouse_id"). Joins("LEFT JOIN warehouses w_source ON w_source.id = pw_source.warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
@@ -1352,8 +1351,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
COALESCE(SUM(sa.qty), 0) AS qty_out, COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price COALESCE(p.product_price, 0) AS price
`). `).
Joins("JOIN laying_transfer_sources lts ON lts.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()). Joins("JOIN laying_transfers lt ON lt.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = lt.id"). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = lt.id").
Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = ltt.product_warehouse_id"). Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = ltt.product_warehouse_id").
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
@@ -1365,7 +1363,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") Group("lt.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price")
outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p") outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p")
outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery)
if err != nil { if err != nil {
@@ -32,6 +32,44 @@ func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error)
} }
sort.Strings(flagList) sort.Strings(flagList)
productMainFlags := utils.ProductMainFlags()
productMainFlagValues := make([]string, len(productMainFlags))
for i, flag := range productMainFlags {
productMainFlagValues[i] = string(flag)
}
type productFlagOption struct {
Flag string `json:"flag"`
SubFlags []string `json:"sub_flags"`
AllowWithoutSubFlag bool `json:"allow_without_sub_flag"`
}
productOptions := utils.ProductFlagOptions()
productFlagOptions := make([]productFlagOption, 0, len(productOptions))
for _, option := range productOptions {
subFlags := make([]string, len(option.SubFlags))
for i, subFlag := range option.SubFlags {
subFlags[i] = string(subFlag)
}
productFlagOptions = append(productFlagOptions, productFlagOption{
Flag: string(option.Flag),
SubFlags: subFlags,
AllowWithoutSubFlag: option.AllowWithoutSubFlag,
})
}
productSubFlagToFlagRaw := utils.ProductSubFlagToFlag()
productSubFlagToFlag := make(map[string]string, len(productSubFlagToFlagRaw))
for subFlag, flag := range productSubFlagToFlagRaw {
productSubFlagToFlag[string(subFlag)] = string(flag)
}
legacyAliasesRaw := utils.LegacyFlagTypeAliases()
legacyAliases := make(map[string]string, len(legacyAliasesRaw))
for legacy, canonical := range legacyAliasesRaw {
legacyAliases[string(legacy)] = string(canonical)
}
type approvalStepConstant struct { type approvalStepConstant struct {
StepNumber uint16 `json:"step_number"` StepNumber uint16 `json:"step_number"`
StepName string `json:"step_name"` StepName string `json:"step_name"`
@@ -96,9 +134,15 @@ func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error)
"BISNIS", "BISNIS",
"INDIVIDUAL", "INDIVIDUAL",
}, },
"adjustment": map[string]interface{}{ "adjustment": map[string]interface{}{
"transaction_subtypes": adjustmentSubtypesByType, "transaction_subtypes": adjustmentSubtypesByType,
}, },
"approval_workflows": approvalWorkflows, "legacy_flag_aliases": legacyAliases,
}, nil "product_flag_mapping": map[string]interface{}{
"flags": productMainFlagValues,
"options": productFlagOptions,
"sub_flag_to_flag": productSubFlagToFlag,
},
"approval_workflows": approvalWorkflows,
}, nil
} }
@@ -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",
})
}
@@ -26,6 +26,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
productRepo := rproduct.NewProductRepository(db) productRepo := rproduct.NewProductRepository(db)
adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db) adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db)
@@ -40,6 +41,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat
fifoStockV2Service, fifoStockV2Service,
validate, validate,
projectFlockKandangRepo, projectFlockKandangRepo,
projectFlockPopulationRepo,
) )
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -2,12 +2,12 @@ package repositories
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -15,9 +15,19 @@ import (
type AdjustmentStockRepository interface { type AdjustmentStockRepository interface {
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error)
GetByIDForUpdate(ctx context.Context, id uint) (*entity.AdjustmentStock, error)
FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error)
FindProductIDByProductWarehouseID(ctx context.Context, productWarehouseID uint) (uint, error)
FindRoutesByFunctionCode(ctx context.Context, productID uint, functionCode string) ([]AdjustmentRouteResolution, error) FindRoutesByFunctionCode(ctx context.Context, productID uint, functionCode string) ([]AdjustmentRouteResolution, error)
FindOverconsumeRule(ctx context.Context, lane, flagGroupCode, functionCode string) (*bool, error) LoadDownstreamDependencies(ctx context.Context, stockableType string, stockableIDs []uint) ([]AdjustmentDownstreamDependency, error)
FindAyamSourceProductWarehouse(ctx context.Context, warehouseID uint, projectFlockKandangID uint) (*entity.ProductWarehouse, error)
IsAyamProduct(ctx context.Context, productID uint) (bool, error)
CountActiveConsumeAllocationsByUsable(ctx context.Context, usableType string, usableID uint) (int64, error)
UpdateTotalQty(ctx context.Context, id uint, qty float64) error
UpdatePairedAdjustmentID(ctx context.Context, id uint, pairedID uint) error
DeleteStockLogsByAdjustmentID(ctx context.Context, adjustmentID uint) error
DeleteAdjustmentByID(ctx context.Context, id uint) error
ResyncProjectFlockPopulationUsage(ctx context.Context, projectFlockKandangID uint) error
FindHistory(ctx context.Context, filter AdjustmentHistoryFilter, modifier func(*gorm.DB) *gorm.DB) ([]*entity.AdjustmentStock, int64, error) FindHistory(ctx context.Context, filter AdjustmentHistoryFilter, modifier func(*gorm.DB) *gorm.DB) ([]*entity.AdjustmentStock, int64, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB DB() *gorm.DB
@@ -44,6 +54,13 @@ type AdjustmentHistoryFilter struct {
Limit int Limit int
} }
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"`
}
type adjustmentStockRepositoryImpl struct { type adjustmentStockRepositoryImpl struct {
db *gorm.DB db *gorm.DB
} }
@@ -73,6 +90,17 @@ func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, mo
return &record, nil return &record, nil
} }
func (r *adjustmentStockRepositoryImpl) GetByIDForUpdate(ctx context.Context, id uint) (*entity.AdjustmentStock, error) {
var record entity.AdjustmentStock
if err := r.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", id).
Take(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) { func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
type pfkRow struct { type pfkRow struct {
KandangID uint `gorm:"column:kandang_id"` KandangID uint `gorm:"column:kandang_id"`
@@ -91,6 +119,21 @@ func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx
return pfk.KandangID, nil return pfk.KandangID, nil
} }
func (r *adjustmentStockRepositoryImpl) FindProductIDByProductWarehouseID(ctx context.Context, productWarehouseID uint) (uint, error) {
type productRow struct {
ProductID uint `gorm:"column:product_id"`
}
var row productRow
if err := r.db.WithContext(ctx).
Table("product_warehouses").
Select("product_id").
Where("id = ?", productWarehouseID).
Take(&row).Error; err != nil {
return 0, err
}
return row.ProductID, nil
}
func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode( func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
ctx context.Context, ctx context.Context,
productID uint, productID uint,
@@ -122,37 +165,183 @@ func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
return rows, nil return rows, nil
} }
func (r *adjustmentStockRepositoryImpl) FindOverconsumeRule( func (r *adjustmentStockRepositoryImpl) LoadDownstreamDependencies(
ctx context.Context, ctx context.Context,
lane string, stockableType string,
flagGroupCode string, stockableIDs []uint,
functionCode string, ) ([]AdjustmentDownstreamDependency, error) {
) (*bool, error) { if strings.TrimSpace(stockableType) == "" || len(stockableIDs) == 0 {
type selectedRow struct { return nil, nil
AllowOverconsume bool `gorm:"column:allow_overconsume"`
} }
var selected selectedRow var rows []AdjustmentDownstreamDependency
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("fifo_stock_v2_overconsume_rules"). Table("stock_allocations").
Select("allow_overconsume"). Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
Where("is_active = TRUE"). Where("stockable_type = ?", strings.ToUpper(strings.TrimSpace(stockableType))).
Where("lane = ?", lane). Where("stockable_id IN ?", stockableIDs).
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode). Where("status = ?", entity.StockAllocationStatusActive).
Where("(function_code IS NULL OR function_code = ?)", functionCode). Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC"). Where("deleted_at IS NULL").
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC"). Where(
Order("priority ASC, id ASC"). "(usable_type <> ? OR EXISTS (SELECT 1 FROM project_chickins pc WHERE pc.id = stock_allocations.usable_id AND pc.deleted_at IS NULL))",
Limit(1). "PROJECT_CHICKIN",
Take(&selected).Error ).
Group("usable_type, usable_id, function_code, flag_group_code").
Scan(&rows).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err return nil, err
} }
return &selected.AllowOverconsume, nil return rows, nil
}
func (r *adjustmentStockRepositoryImpl) FindAyamSourceProductWarehouse(
ctx context.Context,
warehouseID uint,
projectFlockKandangID uint,
) (*entity.ProductWarehouse, error) {
var sourcePW entity.ProductWarehouse
err := r.db.WithContext(ctx).
Model(&entity.ProductWarehouse{}).
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = product_warehouses.product_id
AND fm.flag_group_code = ?
)
`, entity.FlagableTypeProduct, "AYAM").
Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)).
Order("id ASC").
Take(&sourcePW).Error
if err != nil {
return nil, err
}
return &sourcePW, nil
}
func (r *adjustmentStockRepositoryImpl) IsAyamProduct(ctx context.Context, productID uint) (bool, error) {
if productID == 0 {
return false, nil
}
var count int64
if err := r.db.WithContext(ctx).
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", "AYAM").
Where("f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.flagable_id = ?", productID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *adjustmentStockRepositoryImpl) CountActiveConsumeAllocationsByUsable(
ctx context.Context,
usableType string,
usableID uint,
) (int64, error) {
if strings.TrimSpace(usableType) == "" || usableID == 0 {
return 0, nil
}
var count int64
err := r.db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ?", strings.ToUpper(strings.TrimSpace(usableType))).
Where("usable_id = ?", usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
func (r *adjustmentStockRepositoryImpl) UpdateTotalQty(ctx context.Context, id uint, qty float64) error {
return r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where("id = ?", id).
Update("total_qty", qty).Error
}
func (r *adjustmentStockRepositoryImpl) UpdatePairedAdjustmentID(ctx context.Context, id uint, pairedID uint) error {
return r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where("id = ?", id).
Update("paired_adjustment_id", pairedID).Error
}
func (r *adjustmentStockRepositoryImpl) DeleteStockLogsByAdjustmentID(ctx context.Context, adjustmentID uint) error {
return r.db.WithContext(ctx).
Where("loggable_type = ? AND loggable_id = ?", string(utils.StockLogTypeAdjustment), adjustmentID).
Delete(&entity.StockLog{}).Error
}
func (r *adjustmentStockRepositoryImpl) DeleteAdjustmentByID(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).
Where("id = ?", id).
Delete(&entity.AdjustmentStock{}).Error
}
func (r *adjustmentStockRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
idsSubquery := `
SELECT pfp.id
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
`
updateWithAlloc := `
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id
AND p.id IN (` + idsSubquery + `)
`
resetMissing := `
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE p.id IN (` + idsSubquery + `)
AND NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.stockable_id = p.id
)
`
db := r.db.WithContext(ctx)
if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil {
return err
}
if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil {
return err
}
return nil
} }
func (r *adjustmentStockRepositoryImpl) FindHistory( func (r *adjustmentStockRepositoryImpl) FindHistory(
@@ -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,12 +5,14 @@ import (
"errors" "errors"
"fmt" "fmt"
"math" "math"
"sort"
"strings" "strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
common "gitlab.com/mbugroup/lti-api.git/internal/common/service" common "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
@@ -21,31 +23,33 @@ import (
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/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"
) )
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)
} }
type adjustmentService struct { type adjustmentService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
StockLogsRepository stockLogsRepo.StockLogRepository StockLogsRepository stockLogsRepo.StockLogRepository
WarehouseRepo warehouseRepo.WarehouseRepository WarehouseRepo warehouseRepo.WarehouseRepository
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
ProductRepo productRepo.ProductRepository ProductRepo productRepo.ProductRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository
FifoStockV2Svc common.FifoStockV2Service AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository
FifoStockV2Svc common.FifoStockV2Service
} }
const ( const (
adjustmentLaneStockable = "STOCKABLE" adjustmentLaneStockable = "STOCKABLE"
adjustmentLaneUsable = "USABLE" adjustmentLaneUsable = "USABLE"
flagGroupAyam = "AYAM"
) )
func NewAdjustmentService( func NewAdjustmentService(
@@ -57,37 +61,37 @@ func NewAdjustmentService(
fifoStockV2Svc common.FifoStockV2Service, fifoStockV2Svc common.FifoStockV2Service,
validate *validator.Validate, validate *validator.Validate,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository,
) AdjustmentService { ) AdjustmentService {
return &adjustmentService{ return &adjustmentService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
StockLogsRepository: stockLogsRepo, StockLogsRepository: stockLogsRepo,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo, ProductWarehouseRepo: productWarehouseRepo,
ProductRepo: productRepo, ProductRepo: productRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
AdjustmentStockRepository: adjustmentStockRepo, ProjectFlockPopulationRepo: projectFlockPopulationRepo,
FifoStockV2Svc: fifoStockV2Svc, AdjustmentStockRepository: adjustmentStockRepo,
FifoStockV2Svc: fifoStockV2Svc,
} }
} }
func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("ProductWarehouse.Warehouse.Location").
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("StockLog.CreatedUser")
}
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) {
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil { if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
return nil, err return nil, err
} }
adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, s.withRelations) adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("ProductWarehouse.Warehouse.Location").
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("StockLog.CreatedUser")
})
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
@@ -99,6 +103,250 @@ 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 {
adjustments, err := s.collectAdjustmentsForDelete(ctx, tx, id)
if err != nil {
return err
}
for _, item := range adjustments {
if err := s.deleteSingleAdjustmentInTx(ctx, tx, item, actorID); err != nil {
return err
}
}
return nil
})
}
func (s *adjustmentService) collectAdjustmentsForDelete(ctx context.Context, tx *gorm.DB, id uint) ([]entity.AdjustmentStock, error) {
repoTx := s.AdjustmentStockRepository.WithTx(tx)
adjustment, err := repoTx.GetByIDForUpdate(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load adjustment")
}
adjustments := []entity.AdjustmentStock{*adjustment}
leftPairCode := utils.NormalizeUpper(adjustment.FunctionCode)
isDepletionCode := leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) ||
leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)
if !isDepletionCode {
return adjustments, nil
}
if adjustment.PairedAdjustmentId == nil || *adjustment.PairedAdjustmentId == 0 {
return nil, fiber.NewError(
fiber.StatusBadRequest,
"Adjustment depletion tidak memiliki pasangan valid. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.",
)
}
pair, err := repoTx.GetByIDForUpdate(ctx, *adjustment.PairedAdjustmentId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment depletion (%d) tidak ditemukan. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.", *adjustment.PairedAdjustmentId),
)
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load paired adjustment")
}
rightPairCode := utils.NormalizeUpper(pair.FunctionCode)
isPairDepletionCode := rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) ||
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)
if !isPairDepletionCode {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment %d bukan depletion pair yang valid", pair.Id),
)
}
if pair.PairedAdjustmentId == nil || *pair.PairedAdjustmentId != adjustment.Id {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment depletion tidak konsisten (%d <-> %d). Perbaiki pairing terlebih dahulu.", adjustment.Id, pair.Id),
)
}
isValidPair := (leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) &&
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)) ||
(leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) &&
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn))
if !isValidPair {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan function_code depletion tidak valid (%s <-> %s)", adjustment.FunctionCode, pair.FunctionCode),
)
}
adjustments = append(adjustments, *pair)
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].Id < adjustments[j].Id
})
return adjustments, nil
}
func (s *adjustmentService) deleteSingleAdjustmentInTx(
ctx context.Context,
tx *gorm.DB,
adjustment entity.AdjustmentStock,
actorID uint,
) error {
repoTx := s.AdjustmentStockRepository.WithTx(tx)
productID, err := repoTx.FindProductIDByProductWarehouseID(ctx, adjustment.ProductWarehouseId)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load product warehouse context")
}
routeMeta, err := s.resolveRouteByFunctionCode(ctx, productID, adjustment.FunctionCode)
if err != nil {
return err
}
isAyamProduct, err := repoTx.IsAyamProduct(ctx, productID)
if err != nil {
s.Log.Errorf("Failed to resolve AYAM flag for product %d: %+v", productID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product flag")
}
stockLogRepoTx := stockLogsRepo.NewStockLogRepository(tx)
notes := fmt.Sprintf("ADJUSTMENT DELETE#%s", utils.NormalizeTrim(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 := repoTx.UpdateTotalQty(ctx, adjustment.Id, 0); 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:
activeBeforeRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations before rollback")
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, common.FifoStockV2RollbackRequest{
ProductWarehouseID: adjustment.ProductWarehouseId,
Usable: common.FifoStockV2Ref{
ID: adjustment.Id,
LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(),
},
Reason: notes,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to rollback FIFO v2 adjustment: %v", err))
}
activeAfterRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations after rollback")
}
if activeAfterRollback > 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Adjustment tidak dapat dihapus karena masih ada alokasi aktif ADJUSTMENT_OUT=%d (sebelum rollback=%d, sesudah rollback=%d).",
adjustment.Id,
activeBeforeRollback,
activeAfterRollback,
),
)
}
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 := repoTx.DeleteStockLogsByAdjustmentID(ctx, adjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment stock logs")
}
if err := repoTx.DeleteAdjustmentByID(ctx, adjustment.Id); 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
@@ -117,12 +365,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero")
} }
functionCode := strings.ToUpper(strings.TrimSpace(req.TransactionSubtype)) functionCode := utils.NormalizeUpper(req.TransactionSubtype)
if functionCode == "" { if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(req.TransactionSubType)) functionCode = utils.NormalizeUpper(req.TransactionSubType)
} }
if functionCode == "" { if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(req.FunctionCode)) functionCode = utils.NormalizeUpper(req.FunctionCode)
} }
if functionCode == "" { if functionCode == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required") return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required")
@@ -139,9 +387,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, err return nil, err
} }
note := strings.TrimSpace(req.Notes) note := utils.NormalizeTrim(req.Notes)
if note == "" { if note == "" {
note = strings.TrimSpace(req.Note) note = utils.NormalizeTrim(req.Note)
} }
grandTotal := math.Round((qty*req.Price)*1000) / 1000 grandTotal := math.Round((qty*req.Price)*1000) / 1000
@@ -223,8 +471,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
} }
sourcePW, err := s.resolveAyamSourceProductWarehouse(ctx, tx, warehouseID, *projectFlockKandangID) sourcePW, err := adjustmentStockRepoTX.FindAyamSourceProductWarehouse(ctx, warehouseID, *projectFlockKandangID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan")
}
return err return err
} }
if err := common.EnsureProjectFlockNotClosedForProductWarehouses( if err := common.EnsureProjectFlockNotClosedForProductWarehouses(
@@ -280,6 +531,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err := adjustmentStockRepoTX.CreateOne(ctx, destinationAdjustment, nil); err != nil { if err := adjustmentStockRepoTX.CreateOne(ctx, destinationAdjustment, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion destination adjustment stock record") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion destination adjustment stock record")
} }
if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, sourceAdjustment.Id, destinationAdjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion source adjustment pair")
}
if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, destinationAdjustment.Id, sourceAdjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion destination adjustment pair")
}
sourceAdjustment.PairedAdjustmentId = &destinationAdjustment.Id
destinationAdjustment.PairedAdjustmentId = &sourceAdjustment.Id
sourceAsOf := sourceAdjustment.CreatedAt sourceAsOf := sourceAdjustment.CreatedAt
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
@@ -309,6 +568,22 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh depletion destination adjustment stock") return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh depletion destination adjustment stock")
} }
consumedPopulationQty := refreshedSource.UsageQty + refreshedSource.PendingQty
if consumedPopulationQty > 0 {
if err := s.allocatePopulationForDepletionAdjustment(
ctx,
tx,
*projectFlockKandangID,
sourcePW.Id,
refreshedSource.Id,
consumedPopulationQty,
); err != nil {
return err
}
if err := adjustmentStockRepoTX.ResyncProjectFlockPopulationUsage(ctx, *projectFlockKandangID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to resync project flock population usage")
}
}
if err := s.createAdjustmentStockLog( if err := s.createAdjustmentStockLog(
ctx, ctx,
@@ -481,29 +756,80 @@ func (s *adjustmentService) resolveRouteByFunctionCode(
} }
} }
func (s *adjustmentService) resolveOverconsumePolicy( func (s *adjustmentService) resolveAdjustmentDependenciesAndPolicy(
ctx context.Context, ctx context.Context,
route *adjustmentStockRepo.AdjustmentRouteResolution, tx *gorm.DB,
) (bool, error) { stockableType string,
if route == nil { stockableIDs []uint,
return false, fmt.Errorf("route is required") ) ([]adjustmentStockRepo.AdjustmentDownstreamDependency, bool, error) {
} deps, err := s.AdjustmentStockRepository.WithTx(tx).LoadDownstreamDependencies(ctx, stockableType, stockableIDs)
defaultValue := route.AllowPendingDefault
selected, err := s.AdjustmentStockRepository.FindOverconsumeRule(
ctx,
route.Lane,
route.FlagGroupCode,
route.FunctionCode,
)
if err != nil { if err != nil {
return false, err 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 selected == nil { if len(deps) == 0 {
return defaultValue, nil return nil, true, nil
} }
return *selected, 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 formatAdjustmentDependencySummary(rows []adjustmentStockRepo.AdjustmentDownstreamDependency) string {
if len(rows) == 0 {
return "-"
}
grouped := make(map[string]map[uint64]struct{})
for _, row := range rows {
label := utils.NormalizeUpper(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) {
@@ -532,46 +858,6 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context,
return uint(projectFlockKandang.Id), nil return uint(projectFlockKandang.Id), nil
} }
func (s *adjustmentService) resolveAyamSourceProductWarehouse(
ctx context.Context,
tx *gorm.DB,
warehouseID uint,
projectFlockKandangID uint,
) (*entity.ProductWarehouse, error) {
if tx == nil {
return nil, fmt.Errorf("transaction is required")
}
if projectFlockKandangID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id tidak valid untuk depletion conversion")
}
var sourcePW entity.ProductWarehouse
err := tx.WithContext(ctx).
Model(&entity.ProductWarehouse{}).
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = product_warehouses.product_id
AND fm.flag_group_code = ?
)
`, entity.FlagableTypeProduct, flagGroupAyam).
Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)).
Order("id ASC").
Take(&sourcePW).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan")
}
return nil, err
}
return &sourcePW, nil
}
func (s *adjustmentService) createAdjustmentStockLog( func (s *adjustmentService) createAdjustmentStockLog(
ctx context.Context, ctx context.Context,
stockLogRepo stockLogsRepo.StockLogRepository, stockLogRepo stockLogsRepo.StockLogRepository,
@@ -614,6 +900,47 @@ func (s *adjustmentService) createAdjustmentStockLog(
return stockLogRepo.CreateOne(ctx, newLog, nil) return stockLogRepo.CreateOne(ctx, newLog, nil)
} }
func (s *adjustmentService) allocatePopulationForDepletionAdjustment(
ctx context.Context,
tx *gorm.DB,
projectFlockKandangID uint,
sourceProductWarehouseID uint,
adjustmentID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if projectFlockKandangID == 0 || sourceProductWarehouseID == 0 || adjustmentID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid depletion adjustment population context")
}
if s.ProjectFlockPopulationRepo == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not available")
}
popRepoTx := s.ProjectFlockPopulationRepo.WithTx(tx)
populations, err := popRepoTx.GetByProjectFlockKandangIDAndProductWarehouseID(ctx, projectFlockKandangID, sourceProductWarehouseID)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk depletion adjustment")
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
sourceProductWarehouseID,
fifo.UsableKeyAdjustmentOut.String(),
adjustmentID,
consumeQty,
)
}
func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) { func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) {
if err := s.Validate.Struct(query); err != nil { if err := s.Validate.Struct(query); err != nil {
return nil, 0, err return nil, 0, err
@@ -656,11 +983,11 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
} }
} }
functionCode := strings.ToUpper(strings.TrimSpace(query.TransactionSubtype)) functionCode := utils.NormalizeUpper(query.TransactionSubtype)
if functionCode == "" { if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(query.FunctionCode)) functionCode = utils.NormalizeUpper(query.FunctionCode)
} }
transactionType := strings.ToUpper(strings.TrimSpace(query.TransactionType)) transactionType := utils.NormalizeUpper(query.TransactionType)
adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory( adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory(
c.Context(), c.Context(),
@@ -32,6 +32,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
Flags: c.Query("flags", ""), Flags: c.Query("flags", ""),
KandangId: uint(c.QueryInt("kandang_id", 0)), KandangId: uint(c.QueryInt("kandang_id", 0)),
TransferContext: c.Query(utils.TransferContextKey, ""), TransferContext: c.Query(utils.TransferContextKey, ""),
StockMode: c.Query("stock_mode", ""),
Type: c.Query("type", ""), Type: c.Query("type", ""),
} }
@@ -12,10 +12,11 @@ import (
// === DTO Structs === // === DTO Structs ===
type ProductWarehouseRelationDTO struct { type ProductWarehouseRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
ProductId uint `json:"product_id"` ProductId uint `json:"product_id"`
WarehouseId uint `json:"warehouse_id"` WarehouseId uint `json:"warehouse_id"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
TransferAvailableQty *float64 `json:"transfer_available_qty,omitempty"`
} }
type ProductWarehouseListDTO struct { type ProductWarehouseListDTO struct {
@@ -61,10 +62,11 @@ type ProjectFlockRelationDTO struct {
func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO {
return ProductWarehouseRelationDTO{ return ProductWarehouseRelationDTO{
Id: e.Id, Id: e.Id,
ProductId: e.ProductId, // Field yang benar dari entity ProductId: e.ProductId, // Field yang benar dari entity
WarehouseId: e.WarehouseId, // Field yang benar dari entity WarehouseId: e.WarehouseId, // Field yang benar dari entity
Quantity: e.Quantity, Quantity: e.Quantity,
TransferAvailableQty: e.AvailableQty,
} }
} }
@@ -2,6 +2,7 @@ package service
import ( import (
"errors" "errors"
"strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -27,6 +28,8 @@ type productWarehouseService struct {
KandangRepo kandangrepo.KandangRepository KandangRepo kandangrepo.KandangRepository
} }
const stockModeExcludeChickin = "exclude_chickin"
func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService { func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService {
return &productWarehouseService{ return &productWarehouseService{
Log: utils.Log, Log: utils.Log,
@@ -189,6 +192,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
s.Log.Errorf("Failed to get productWarehouses: %+v", err) s.Log.Errorf("Failed to get productWarehouses: %+v", err)
return nil, 0, err return nil, 0, err
} }
productWarehouses, err = s.applyTransferAvailableQty(c, params, productWarehouses)
if err != nil {
return nil, 0, err
}
return productWarehouses, total, nil return productWarehouses, total, nil
} }
@@ -229,3 +237,80 @@ func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductW
} }
return productWarehouse, nil return productWarehouse, nil
} }
func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params *validation.Query, rows []entity.ProductWarehouse) ([]entity.ProductWarehouse, error) {
if len(rows) == 0 {
return rows, nil
}
if params == nil ||
params.TransferContext != utils.TransferContextInventoryTransfer ||
params.StockMode != stockModeExcludeChickin {
return rows, nil
}
ayamPWIDs := make([]uint, 0)
for i := range rows {
if isAyamProductByFlags(rows[i].Product.Flags) {
ayamPWIDs = append(ayamPWIDs, rows[i].Id)
}
}
if len(ayamPWIDs) == 0 {
return rows, nil
}
type populationRemainingRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
RemainingQty float64 `gorm:"column:remaining_qty"`
}
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))
for i := range rows {
row := rows[i]
if !isAyamProductByFlags(row.Product.Flags) {
filtered = append(filtered, row)
continue
}
available := row.Quantity - populationRemainingByPW[row.Id]
if available < 0 {
available = 0
}
row.AvailableQty = &available
if available <= 0 {
continue
}
filtered = append(filtered, row)
}
return filtered, nil
}
func isAyamProductByFlags(flags []entity.Flag) bool {
for _, flag := range flags {
if utils.CanonicalFlagType(strings.TrimSpace(flag.Name)) == utils.FlagAyam {
return true
}
}
return false
}
@@ -20,5 +20,6 @@ type Query struct {
Flags string `query:"flags" validate:"omitempty"` Flags string `query:"flags" validate:"omitempty"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
StockMode string `query:"stock_mode" validate:"omitempty,oneof=exclude_chickin"`
Type string `query:"type" validate:"omitempty"` Type string `query:"type" validate:"omitempty"`
} }
@@ -109,3 +109,23 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error {
Data: dto.ToTransferDetailDTO(*result), Data: dto.ToTransferDetailDTO(*result),
}) })
} }
func (u *TransferController) 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.TransferService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete transfer successfully",
})
}
@@ -18,5 +18,6 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ
route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne)
route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne)
route.Delete("/:id", m.RequirePermissions(m.P_TransferDeleteOne), ctrl.DeleteOne)
} }
@@ -5,13 +5,14 @@ import (
"errors" "errors"
"fmt" "fmt"
"mime/multipart" "mime/multipart"
"sort"
"strings" "strings"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
@@ -25,12 +26,14 @@ import (
"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 TransferService interface { type TransferService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error)
CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
} }
type transferService struct { type transferService struct {
@@ -51,6 +54,15 @@ type transferService struct {
ExpenseBridge TransferExpenseBridge ExpenseBridge TransferExpenseBridge
} }
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,
@@ -106,6 +118,7 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db = db.Where("stock_transfers.deleted_at IS NULL")
if scope.Restrict { if scope.Restrict {
if len(scope.IDs) == 0 { if len(scope.IDs) == 0 {
return db.Where("1 = 0") return db.Where("1 = 0")
@@ -147,6 +160,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
Where("stock_transfers.id = ?", id). Where("stock_transfers.id = ?", id).
Where("stock_transfers.deleted_at IS NULL").
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs). Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
Count(&count).Error; err != nil { Count(&count).Error; err != nil {
return nil, err return nil, err
@@ -157,7 +171,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
} }
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db) return s.withRelations(db).Where("stock_transfers.deleted_at IS NULL")
}) })
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -513,18 +527,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID))
} }
if strings.EqualFold(flagGroupCode, "AYAM") && outUsageQty > 0 {
if err := s.allocatePopulationForStockTransferOut(
c.Context(),
tx,
detail,
uint(*detail.SourceProductWarehouseID),
outUsageQty,
); err != nil {
return err
}
}
stockLogDecrease := &entity.StockLog{ stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID), ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: uint(actorID), CreatedBy: uint(actorID),
@@ -633,55 +635,208 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return result, nil return result, nil
} }
func (s *transferService) allocatePopulationForStockTransferOut( func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error {
ctx context.Context, if err := s.ensureTransferAccess(c.Context(), id, c); err != nil {
tx *gorm.DB, return err
detail *entity.StockTransferDetail,
sourceProductWarehouseID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
} }
if tx == nil { if s.FifoStockV2Svc == nil {
return errors.New("transaction is required") return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
if detail == nil || detail.Id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Data transfer detail tidak valid")
}
if sourceProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Gudang sumber tidak valid")
} }
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, sourceProductWarehouseID, nil) actorID, err := m.ActorIDFromContext(c)
if err != nil { if err != nil {
return err return err
} }
if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 {
var deletedDetails []entity.StockTransferDetail
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
var transfer entity.StockTransfer
if err := tx.WithContext(c.Context()).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", uint64(id)).
Where("deleted_at IS NULL").
Take(&transfer).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
}
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
}
var details []entity.StockTransferDetail
if err := tx.WithContext(c.Context()).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Order("id ASC").
Find(&details).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer")
}
if len(details) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk")
}
detailIDs := make([]uint64, 0, len(details))
for _, detail := range details {
detailIDs = append(detailIDs, detail.Id)
}
if err := s.ensureDeletePolicyForDownstreamConsumption(c.Context(), tx, detailIDs); err != nil {
return err
}
type reflowKey struct {
flagGroupCode string
productWarehouseID uint
}
destReflows := make(map[reflowKey]struct{})
for _, detail := range details {
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id))
}
if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id))
}
flagGroupCode, err := s.resolveTransferFlagGroup(c.Context(), tx, uint(detail.ProductId))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err))
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Usable: commonSvc.FifoStockV2Ref{
ID: uint(detail.Id),
LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(),
FunctionCode: "STOCK_TRANSFER_OUT",
},
Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber),
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err))
}
releasedQty := 0.0
if rollbackRes != nil {
releasedQty = rollbackRes.ReleasedQty
}
if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty),
)
}
if releasedQty > 1e-6 {
if err := s.appendStockLog(
c.Context(),
stockLogRepoTx,
uint(*detail.SourceProductWarehouseID),
actorID,
releasedQty,
0,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return err
}
}
destDecreaseQty := detail.TotalQty
if destDecreaseQty <= 1e-6 {
destDecreaseQty = detail.UsageQty
}
if destDecreaseQty > 1e-6 {
if err := s.appendStockLog(
c.Context(),
stockLogRepoTx,
uint(*detail.DestProductWarehouseID),
actorID,
0,
destDecreaseQty,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return err
}
}
destReflows[reflowKey{
flagGroupCode: flagGroupCode,
productWarehouseID: uint(*detail.DestProductWarehouseID),
}] = struct{}{}
}
now := time.Now().UTC()
if err := tx.WithContext(c.Context()).
Where("stock_transfer_detail_id IN ?", detailIDs).
Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer")
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransferDelivery{}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer")
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransferDetail{}).
Where("id IN ?", detailIDs).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer")
}
asOf := transfer.TransferDate
for key := range destReflows {
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: key.flagGroupCode,
ProductWarehouseID: key.productWarehouseID,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err))
}
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransfer{}).
Where("id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
}
deletedDetails = append(deletedDetails, details...)
return nil return nil
} })
populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(
ctx,
*pw.ProjectFlockKandangId,
sourceProductWarehouseID,
)
if err != nil { if err != nil {
return err if fiberErr, ok := err.(*fiber.Error); ok {
} return fiberErr
if len(populations) == 0 { }
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk transfer") return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
} }
return fifoV2.AllocatePopulationConsumption( if len(deletedDetails) > 0 && s.ExpenseBridge != nil {
ctx, if err := s.ExpenseBridge.OnItemsDeleted(c.Context(), uint64(id), deletedDetails); err != nil {
tx, s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err)
populations, return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense")
sourceProductWarehouseID, }
fifo.UsableKeyStockTransferOut.String(), }
uint(detail.Id),
consumeQty, return nil
)
} }
func (s *transferService) resolveTransferFlagGroup( func (s *transferService) resolveTransferFlagGroup(
@@ -757,3 +912,264 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
return uint(projectFlockKandang.Id), nil return uint(projectFlockKandang.Id), nil
} }
func (s *transferService) ensureTransferAccess(ctx context.Context, id uint, c *fiber.Ctx) error {
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
if err != nil {
return err
}
if !scope.Restrict {
return nil
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
var count int64
if err := s.StockTransferRepo.DB().WithContext(ctx).
Table("stock_transfers").
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
Where("stock_transfers.id = ?", id).
Where("stock_transfers.deleted_at IS NULL").
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
return nil
}
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)
}
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, 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 "-"
}
dependencyMap := make(map[string]map[uint64]struct{})
for _, row := range rows {
label := mapTransferDownstreamUsableLabel(row.UsableType)
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)
details := make([]string, 0, len(labels))
for _, label := range labels {
ids := sortedUint64Keys(dependencyMap[label])
details = append(details, fmt.Sprintf("%s=%s", label, joinUint64(ids)))
}
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 {
switch strings.ToUpper(strings.TrimSpace(usableType)) {
case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String():
return "Recording"
case fifo.UsableKeyProjectChickin.String():
return "Chickin"
case fifo.UsableKeyMarketingDelivery.String():
return "Marketing"
case fifo.UsableKeyTransferToLayingOut.String():
return "TransferToLaying"
case fifo.UsableKeyStockTransferOut.String():
return "TransferStock"
case fifo.UsableKeyAdjustmentOut.String():
return "Adjustment"
default:
return strings.ToUpper(strings.TrimSpace(usableType))
}
}
func sortedUint64Keys(input map[uint64]struct{}) []uint64 {
if len(input) == 0 {
return nil
}
out := make([]uint64, 0, len(input))
for id := range input {
if id == 0 {
continue
}
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func joinUint64(values []uint64) string {
if len(values) == 0 {
return "-"
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, fmt.Sprintf("%d", value))
}
return strings.Join(parts, "|")
}
func (s *transferService) appendStockLog(
ctx context.Context,
stockLogRepo rStockLogs.StockLogRepository,
productWarehouseID uint,
actorID uint,
increase float64,
decrease float64,
loggableID uint,
notes string,
) error {
if productWarehouseID == 0 || (increase <= 1e-6 && decrease <= 1e-6) {
return nil
}
stockLog := &entity.StockLog{
ProductWarehouseId: productWarehouseID,
CreatedBy: actorID,
Increase: increase,
Decrease: decrease,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: loggableID,
Notes: notes,
}
stockLogs, err := stockLogRepo.GetByProductWarehouse(ctx, productWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLog.Stock = latestStockLog.Stock + increase - decrease
} else {
stockLog.Stock = increase - decrease
}
if err := stockLogRepo.CreateOne(ctx, stockLog, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat stock log saat delete transfer")
}
return nil
}
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
@@ -343,17 +344,22 @@ func (s productionStandardService) EnsureWeekStart(ctx context.Context, standard
return nil return nil
} }
layingWeekStart := config.LayingWeekStart()
switch strings.ToUpper(category) { switch strings.ToUpper(category) {
case string(utils.ProjectFlockCategoryLaying): case string(utils.ProjectFlockCategoryLaying):
details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID) details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil { if err != nil {
return err return err
} }
startWeek := 0 if len(details) == 0 {
if len(details) > 0 { return fiber.NewError(
startWeek = details[0].Week fiber.StatusBadRequest,
"Standart production tidak tersedia untuk kategori laying",
)
} }
if startWeek != 18 { startWeek := details[0].Week
if startWeek > layingWeekStart {
return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock")
} }
case string(utils.ProjectFlockCategoryGrowing): case string(utils.ProjectFlockCategoryGrowing):
@@ -361,10 +367,13 @@ func (s productionStandardService) EnsureWeekStart(ctx context.Context, standard
if err != nil { if err != nil {
return err return err
} }
startWeek := 0 if len(details) == 0 {
if len(details) > 0 { return fiber.NewError(
startWeek = details[0].Week fiber.StatusBadRequest,
"Standart production tidak tersedia untuk kategori growing",
)
} }
startWeek := details[0].Week
if startWeek != 1 { if startWeek != 1 {
return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock")
} }
@@ -381,7 +390,7 @@ func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, stan
upperCategory := strings.ToUpper(category) upperCategory := strings.ToUpper(category)
weekBase := 1 weekBase := 1
if upperCategory == string(utils.ProjectFlockCategoryLaying) { if upperCategory == string(utils.ProjectFlockCategoryLaying) {
weekBase = 18 weekBase = config.LayingWeekStart()
} }
week := ((day - 1) / 7) + weekBase week := ((day - 1) / 7) + weekBase
if week <= 0 { if week <= 0 {
@@ -30,6 +30,14 @@ func (u *ProductController) GetAll(c *fiber.Ctx) error {
ProductCategoryID: c.QueryInt("product_category_id", 0), ProductCategoryID: c.QueryInt("product_category_id", 0),
} }
if isDepletionParam := c.Query("is_depletion", ""); isDepletionParam != "" {
value, err := strconv.ParseBool(isDepletionParam)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid is_depletion value")
}
query.IsDepletion = &value
}
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
@@ -7,6 +7,7 @@ import (
productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto"
uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
// === DTO Structs === // === DTO Structs ===
@@ -17,6 +18,9 @@ type ProductRelationDTO struct {
ProductPrice float64 `gorm:"type:numeric(15,3);not null"` ProductPrice float64 `gorm:"type:numeric(15,3);not null"`
SellingPrice *float64 `gorm:"type:numeric(15,3)"` SellingPrice *float64 `gorm:"type:numeric(15,3)"`
Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"`
Flag *string `json:"flag,omitempty"`
SubFlag *string `json:"sub_flag,omitempty"`
SubFlags *[]string `json:"sub_flags,omitempty"`
Flags *[]string `json:"flags,omitempty"` Flags *[]string `json:"flags,omitempty"`
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
Suppliers []ProductSupplierDTO `json:"suppliers"` Suppliers []ProductSupplierDTO `json:"suppliers"`
@@ -31,6 +35,9 @@ type ProductListDTO struct {
SellingPrice *float64 `json:"selling_price,omitempty"` SellingPrice *float64 `json:"selling_price,omitempty"`
Tax *float64 `json:"tax,omitempty"` Tax *float64 `json:"tax,omitempty"`
ExpiryPeriod *int `json:"expiry_period,omitempty"` ExpiryPeriod *int `json:"expiry_period,omitempty"`
Flag *string `json:"flag,omitempty"`
SubFlag *string `json:"sub_flag,omitempty"`
SubFlags []string `json:"sub_flags,omitempty"`
Flags []string `json:"flags"` Flags []string `json:"flags"`
Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"`
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
@@ -59,6 +66,13 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO {
for i, f := range e.Flags { for i, f := range e.Flags {
flags[i] = f.Name flags[i] = f.Name
} }
flag, subFlag, subFlags := resolveProductFlagAndSubFlags(flags)
var subFlagsRef *[]string
if len(subFlags) > 0 {
values := make([]string, len(subFlags))
copy(values, subFlags)
subFlagsRef = &values
}
var uomRef *uomDTO.UomRelationDTO var uomRef *uomDTO.UomRelationDTO
if e.Uom.Id != 0 { if e.Uom.Id != 0 {
@@ -77,6 +91,9 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO {
Name: e.Name, Name: e.Name,
ProductPrice: e.ProductPrice, ProductPrice: e.ProductPrice,
SellingPrice: e.SellingPrice, SellingPrice: e.SellingPrice,
Flag: flag,
SubFlag: subFlag,
SubFlags: subFlagsRef,
Flags: &flags, Flags: &flags,
Uom: uomRef, Uom: uomRef,
ProductCategory: categoryRef, ProductCategory: categoryRef,
@@ -101,6 +118,7 @@ func ToProductListDTO(e entity.Product) ProductListDTO {
for i, f := range e.Flags { for i, f := range e.Flags {
flags[i] = f.Name flags[i] = f.Name
} }
flag, subFlag, subFlags := resolveProductFlagAndSubFlags(flags)
var uomRef *uomDTO.UomRelationDTO var uomRef *uomDTO.UomRelationDTO
if e.Uom.Id != 0 { if e.Uom.Id != 0 {
@@ -111,6 +129,9 @@ func ToProductListDTO(e entity.Product) ProductListDTO {
return ProductListDTO{ return ProductListDTO{
Id: e.Id, Id: e.Id,
Name: e.Name, Name: e.Name,
Flag: flag,
SubFlag: subFlag,
SubFlags: subFlags,
Flags: flags, Flags: flags,
Uom: uomRef, Uom: uomRef,
Brand: e.Brand, Brand: e.Brand,
@@ -141,6 +162,58 @@ func ToProductDetailDTO(e entity.Product) ProductDetailDTO {
} }
} }
func resolveProductFlagAndSubFlags(flags []string) (*string, *string, []string) {
normalized := utils.NormalizeFlagTypes(flags)
if len(normalized) == 0 {
return nil, nil, nil
}
available := make(map[utils.FlagType]struct{}, len(normalized))
for _, flag := range normalized {
available[flag] = struct{}{}
}
var selectedFlag utils.FlagType
for _, mainFlag := range utils.ProductMainFlags() {
if _, ok := available[mainFlag]; ok {
selectedFlag = mainFlag
break
}
}
if selectedFlag == "" {
subToMain := utils.ProductSubFlagToFlag()
for _, flag := range normalized {
if parent, ok := subToMain[flag]; ok {
selectedFlag = parent
break
}
}
}
if selectedFlag == "" {
return nil, nil, nil
}
flag := string(selectedFlag)
var subFlag *string
subFlagValues := make([]string, 0)
subFlagsByMain := utils.ProductSubFlagsByFlag()
for _, sub := range subFlagsByMain[selectedFlag] {
if _, ok := available[sub]; ok {
subFlagValues = append(subFlagValues, string(sub))
}
}
if len(subFlagValues) > 0 {
first := subFlagValues[0]
subFlag = &first
}
return &flag, subFlag, subFlagValues
}
func toProductSupplierDTOs(relations []entity.ProductSupplier) []ProductSupplierDTO { func toProductSupplierDTOs(relations []entity.ProductSupplier) []ProductSupplierDTO {
if len(relations) == 0 { if len(relations) == 0 {
return make([]ProductSupplierDTO, 0) return make([]ProductSupplierDTO, 0)
@@ -31,6 +31,12 @@ type productService struct {
Repository repository.ProductRepository Repository repository.ProductRepository
} }
var depletionProductFlags = []string{
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagAyamMati),
}
func normalizeProductFlags(raw []string) ([]string, error) { func normalizeProductFlags(raw []string) ([]string, error) {
normalized, invalid := utils.NormalizeFlagsForGroup(raw, utils.FlagGroupProduct) normalized, invalid := utils.NormalizeFlagsForGroup(raw, utils.FlagGroupProduct)
if len(invalid) > 0 { if len(invalid) > 0 {
@@ -41,6 +47,159 @@ func normalizeProductFlags(raw []string) ([]string, error) {
return utils.FlagTypesToStrings(normalized), nil return utils.FlagTypesToStrings(normalized), nil
} }
func productMainFlagOptionsString() []string {
mainFlags := utils.ProductMainFlags()
result := make([]string, len(mainFlags))
for i, flag := range mainFlags {
result[i] = string(flag)
}
return result
}
func productSubFlagOptionsString(flag utils.FlagType) []string {
subFlagsByFlag := utils.ProductSubFlagsByFlag()
subFlags := subFlagsByFlag[flag]
result := make([]string, len(subFlags))
for i, subFlag := range subFlags {
result[i] = string(subFlag)
}
return result
}
func normalizeStructuredSubFlagsInput(subFlagRaw *string, subFlagsRaw []string, hasSubFlagsField bool) ([]utils.FlagType, error) {
values := make([]string, 0, len(subFlagsRaw)+1)
if subFlagRaw != nil {
single := strings.TrimSpace(*subFlagRaw)
if single == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "sub_flag cannot be empty")
}
values = append(values, single)
}
if hasSubFlagsField {
for _, raw := range subFlagsRaw {
item := strings.TrimSpace(raw)
if item == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "sub_flags cannot contain empty value")
}
values = append(values, item)
}
}
if len(values) == 0 {
return nil, nil
}
return utils.NormalizeFlagTypes(values), nil
}
func resolveProductFlagsFromFlagInput(flagRaw *string, subFlagRaw *string, subFlagsRaw []string, hasSubFlagsField bool) ([]string, bool, error) {
if flagRaw == nil && subFlagRaw == nil && !hasSubFlagsField {
return nil, false, nil
}
if flagRaw == nil && (subFlagRaw != nil || hasSubFlagsField) {
return nil, false, fiber.NewError(fiber.StatusBadRequest, "flag is required when sub_flag/sub_flags is provided")
}
flagText := strings.TrimSpace(*flagRaw)
if flagText == "" {
return nil, false, fiber.NewError(fiber.StatusBadRequest, "flag cannot be empty")
}
flag := utils.CanonicalFlagType(flagText)
if !utils.IsProductMainFlag(flag) {
return nil, false, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Invalid product flag: %s. Allowed flags: %s", flagText, strings.Join(productMainFlagOptionsString(), ", ")),
)
}
out := []string{string(flag)}
normalizedSubFlags, err := normalizeStructuredSubFlagsInput(subFlagRaw, subFlagsRaw, hasSubFlagsField)
if err != nil {
return nil, false, err
}
if len(normalizedSubFlags) == 0 {
if !utils.ProductFlagAllowWithoutSubFlag(flag) {
return nil, false, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("sub_flag/sub_flags is required for flag %s", string(flag)),
)
}
normalizedOut, normalizeErr := normalizeProductFlags(out)
if normalizeErr != nil {
return nil, false, normalizeErr
}
return normalizedOut, true, nil
}
invalidSubFlags := make([]string, 0)
for _, subFlag := range normalizedSubFlags {
if !utils.IsValidProductSubFlag(flag, subFlag) {
invalidSubFlags = append(invalidSubFlags, string(subFlag))
}
}
if len(invalidSubFlags) > 0 {
return nil, false, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Invalid sub_flags %s for flag %s. Allowed sub_flags: %s", strings.Join(invalidSubFlags, ", "), string(flag), strings.Join(productSubFlagOptionsString(flag), ", ")),
)
}
out = append(out, utils.FlagTypesToStrings(normalizedSubFlags)...)
normalizedOut, normalizeErr := normalizeProductFlags(out)
if normalizeErr != nil {
return nil, false, normalizeErr
}
return normalizedOut, true, nil
}
func resolveCreateProductFlags(req *validation.Create) ([]string, error) {
hasStructuredInput := req.Flag != nil || req.SubFlag != nil || req.SubFlags != nil
if len(req.Flags) > 0 && hasStructuredInput {
return nil, fiber.NewError(fiber.StatusBadRequest, "Use either flags or flag/sub_flag/sub_flags, not both")
}
if len(req.Flags) > 0 {
return normalizeProductFlags(req.Flags)
}
flags, _, err := resolveProductFlagsFromFlagInput(req.Flag, req.SubFlag, req.SubFlags, req.SubFlags != nil)
return flags, err
}
func resolveUpdateProductFlags(req *validation.Update) (bool, []string, error) {
hasStructuredInput := req.Flag != nil || req.SubFlag != nil || req.SubFlags != nil
if req.Flags != nil {
if hasStructuredInput {
if len(*req.Flags) > 0 {
return false, nil, fiber.NewError(fiber.StatusBadRequest, "Use either flags or flag/sub_flag/sub_flags, not both")
}
} else {
flags, err := normalizeProductFlags(*req.Flags)
if err != nil {
return false, nil, err
}
return true, flags, nil
}
}
subFlagsRaw := make([]string, 0)
if req.SubFlags != nil {
subFlagsRaw = *req.SubFlags
}
flags, provided, err := resolveProductFlagsFromFlagInput(req.Flag, req.SubFlag, subFlagsRaw, req.SubFlags != nil)
if err != nil {
return false, nil, err
}
return provided, flags, nil
}
func NewProductService(repo repository.ProductRepository, validate *validator.Validate) ProductService { func NewProductService(repo repository.ProductRepository, validate *validator.Validate) ProductService {
return &productService{ return &productService{
Log: utils.Log, Log: utils.Log,
@@ -70,12 +229,32 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db = db.Where("is_visible = ?", true) // Depletion master products are system products and often stored with is_visible = false.
// When requested explicitly via is_depletion=true, include hidden records.
if params.IsDepletion == nil || !*params.IsDepletion {
db = db.Where("is_visible = ?", true)
}
if params.Search != "" { if params.Search != "" {
return db.Where("name ILIKE ?", "%"+params.Search+"%") db = db.Where("name ILIKE ?", "%"+params.Search+"%")
} }
if params.ProductCategoryID != 0 { if params.ProductCategoryID != 0 {
return db.Where("product_category_id = ?", params.ProductCategoryID) db = db.Where("product_category_id = ?", params.ProductCategoryID)
}
if params.IsDepletion != nil {
existsQuery := `
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_type = ?
AND f.flagable_id = products.id
AND UPPER(f.name) IN ?
)
`
if *params.IsDepletion {
db = db.Where(existsQuery, entity.FlagableTypeProduct, depletionProductFlags)
} else {
db = db.Where("NOT "+existsQuery, entity.FlagableTypeProduct, depletionProductFlags)
}
} }
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
@@ -177,7 +356,7 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
} }
} }
productFlags, flagErr := normalizeProductFlags(req.Flags) productFlags, flagErr := resolveCreateProductFlags(req)
if flagErr != nil { if flagErr != nil {
return nil, flagErr return nil, flagErr
} }
@@ -337,13 +516,10 @@ func (s productService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
flagUpdate bool flagUpdate bool
flagValues []string flagValues []string
) )
if req.Flags != nil { var flagErr error
flagUpdate = true flagUpdate, flagValues, flagErr = resolveUpdateProductFlags(req)
var flagErr error if flagErr != nil {
flagValues, flagErr = normalizeProductFlags(*req.Flags) return nil, flagErr
if flagErr != nil {
return nil, flagErr
}
} }
if len(updateBody) == 0 && !supplierUpdate && !flagUpdate { if len(updateBody) == 0 && !supplierUpdate && !flagUpdate {
@@ -6,31 +6,37 @@ type SupplierPrice struct {
} }
type Create struct { type Create struct {
Name string `json:"name" validate:"required_strict,min=3,max=50"` Name string `json:"name" validate:"required_strict,min=3,max=50"`
Brand string `json:"brand" validate:"required_strict,min=2,max=50"` Brand string `json:"brand" validate:"required_strict,min=2,max=50"`
Sku *string `json:"sku,omitempty" validate:"omitempty,max=100"` Sku *string `json:"sku,omitempty" validate:"omitempty,max=100"`
UomID uint `json:"uom_id" validate:"required,gt=0"` UomID uint `json:"uom_id" validate:"required,gt=0"`
ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"` ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"`
ProductPrice float64 `json:"product_price" validate:"required"` ProductPrice float64 `json:"product_price" validate:"required"`
SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"`
Tax *float64 `json:"tax,omitempty" validate:"omitempty"` Tax *float64 `json:"tax,omitempty" validate:"omitempty"`
ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"`
Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"`
Flags []string `json:"flags,omitempty" validate:"omitempty,dive"` Flag *string `json:"flag,omitempty" validate:"omitempty,max=50"`
SubFlag *string `json:"sub_flag,omitempty" validate:"omitempty,max=50"`
SubFlags []string `json:"sub_flags,omitempty" validate:"omitempty,dive,max=50"`
Flags []string `json:"flags,omitempty" validate:"omitempty,dive"`
} }
type Update struct { type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=3"` Name *string `json:"name,omitempty" validate:"omitempty,min=3"`
Brand *string `json:"brand,omitempty" validate:"omitempty,min=2"` Brand *string `json:"brand,omitempty" validate:"omitempty,min=2"`
Sku *string `json:"sku,omitempty" validate:"omitempty"` Sku *string `json:"sku,omitempty" validate:"omitempty"`
UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"`
ProductCategoryID *uint `json:"product_category_id,omitempty" validate:"omitempty,gt=0"` ProductCategoryID *uint `json:"product_category_id,omitempty" validate:"omitempty,gt=0"`
ProductPrice *float64 `json:"product_price,omitempty" validate:"omitempty"` ProductPrice *float64 `json:"product_price,omitempty" validate:"omitempty"`
SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"`
Tax *float64 `json:"tax,omitempty" validate:"omitempty"` Tax *float64 `json:"tax,omitempty" validate:"omitempty"`
ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"`
Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"`
Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"` Flag *string `json:"flag,omitempty" validate:"omitempty,max=50"`
SubFlag *string `json:"sub_flag,omitempty" validate:"omitempty,max=50"`
SubFlags *[]string `json:"sub_flags,omitempty" validate:"omitempty,dive,max=50"`
Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"`
} }
type Query struct { type Query struct {
@@ -38,4 +44,5 @@ type Query struct {
Limit int `query:"limit" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"` ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"`
IsDepletion *bool `query:"is_depletion" validate:"omitempty"`
} }
@@ -151,25 +151,25 @@ func (u *ChickinController) GetOne(c *fiber.Ctx) error {
// }) // })
// } // }
// func (u *ChickinController) DeleteOne(c *fiber.Ctx) error { func (u *ChickinController) DeleteOne(c *fiber.Ctx) error {
// param := c.Params("id") param := c.Params("id")
// id, err := strconv.Atoi(param) id, err := strconv.Atoi(param)
// if err != nil { if err != nil {
// return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
// } }
// if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil { if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil {
// return err return err
// } }
// return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
// JSON(response.Common{ JSON(response.Common{
// Code: fiber.StatusOK, Code: fiber.StatusOK,
// Status: "success", Status: "success",
// Message: "Delete chickin successfully", Message: "Delete chickin successfully",
// }) })
// } }
func (u *ChickinController) Approval(c *fiber.Ctx) error { func (u *ChickinController) Approval(c *fiber.Ctx) error {
req := new(validation.Approve) req := new(validation.Approve)
@@ -3,6 +3,7 @@ package dto
import ( import (
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
areaRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" areaRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
flockRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto"
@@ -35,13 +36,13 @@ type ChickinRelationDTO struct {
} }
type ProjectFlockDTO struct { type ProjectFlockDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Period int `json:"period"` Period int `json:"period"`
Category string `json:"category"` Category string `json:"category"`
Flock *flockRelationDTO.FlockRelationDTO `json:"flock"` Flock *flockRelationDTO.FlockRelationDTO `json:"flock"`
Area *areaRelationDTO.AreaRelationDTO `json:"area"` Area *areaRelationDTO.AreaRelationDTO `json:"area"`
StandardFcr *float64 `json:"standard_fcr"` StandardFcr *float64 `json:"standard_fcr"`
Location *locationRelationDTO.LocationRelationDTO `json:"location"` Location *locationRelationDTO.LocationRelationDTO `json:"location"`
} }
type ProjectFlockKandangDTO struct { type ProjectFlockKandangDTO struct {
@@ -123,13 +124,13 @@ func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO {
location = &mapped location = &mapped
} }
return ProjectFlockDTO{ return ProjectFlockDTO{
Id: e.Id, Id: e.Id,
Period: pfk.Period, Period: pfk.Period,
Category: e.Category, Category: e.Category,
Flock: flock, Flock: flock,
Area: area, Area: area,
StandardFcr: resolveProjectFlockStandardFcr(e), StandardFcr: resolveProjectFlockStandardFcr(e),
Location: location, Location: location,
} }
} }
@@ -219,7 +220,7 @@ func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 {
} }
week := 1 week := 1
if e.Category == string(utils.ProjectFlockCategoryLaying) { if e.Category == string(utils.ProjectFlockCategoryLaying) {
week = 18 week = config.LayingWeekStart()
} }
for _, detail := range e.ProductionStandard.ProductionStandardDetails { for _, detail := range e.ProductionStandard.ProductionStandardDetails {
if detail.Week == week && detail.StandardFCR != nil { if detail.Week == week && detail.StandardFCR != nil {
@@ -19,6 +19,6 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService
route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne)
// route.Patch("/:id", ctrl.UpdateOne) // route.Patch("/:id", ctrl.UpdateOne)
// route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", ctrl.DeleteOne)
route.Post("/approvals",m.RequirePermissions(m.P_ChickinsApproval), ctrl.Approval) route.Post("/approvals",m.RequirePermissions(m.P_ChickinsApproval), ctrl.Approval)
} }
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -11,12 +12,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const (
chickinOutFunctionCode = "CHICKIN_OUT"
chickinUsableLane = "USABLE"
chickinSourceTable = "project_chickins"
)
func reflowChickinScope( func reflowChickinScope(
ctx context.Context, ctx context.Context,
fifoStockV2Svc commonSvc.FifoStockV2Service, fifoStockV2Svc commonSvc.FifoStockV2Service,
@@ -62,9 +57,6 @@ func resolveChickinFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB,
Select("rr.flag_group_code"). Select("rr.flag_group_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE"). Where("rr.is_active = TRUE").
Where("rr.lane = ?", chickinUsableLane).
Where("rr.function_code = ?", chickinOutFunctionCode).
Where("rr.source_table = ?", chickinSourceTable).
Where(` Where(`
EXISTS ( EXISTS (
SELECT 1 SELECT 1
@@ -76,10 +68,13 @@ func resolveChickinFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB,
AND fm.flag_group_code = rr.flag_group_code AND fm.flag_group_code = rr.flag_group_code
) )
`, productWarehouseID, entity.FlagableTypeProduct). `, productWarehouseID, entity.FlagableTypeProduct).
Order("rr.id ASC"). Order("fg.priority ASC, rr.id ASC").
Limit(1). Limit(1).
Take(&selected).Error Take(&selected).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err return "", err
} }
@@ -7,14 +7,14 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
@@ -33,13 +33,14 @@ func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo) approvalService := commonSvc.NewApprovalService(approvalRepo)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
// register workflow steps for chickin approvals // register workflow steps for chickin approvals
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err))
} }
expenseRepo := rExpense.NewExpenseRepository(db) expenseRepo := rExpense.NewExpenseRepository(db)
projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo,kandangRepo, validate) projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, fifoStockV2Service, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, kandangRepo, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ProjectFlockKandangRoutes(router, userService, projectFlockKandangService) ProjectFlockKandangRoutes(router, userService, projectFlockKandangService)
@@ -1,8 +1,10 @@
package service package service
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"math"
"strings" "strings"
"time" "time"
@@ -35,6 +37,7 @@ type projectFlockKandangService struct {
Validate *validator.Validate Validate *validator.Validate
Repository repository.ProjectFlockKandangRepository Repository repository.ProjectFlockKandangRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoStockV2Svc commonSvc.FifoStockV2Service
ExpenseRepo expenseRepo.ExpenseRepository ExpenseRepo expenseRepo.ExpenseRepository
WarehouseRepo rWarehouse.WarehouseRepository WarehouseRepo rWarehouse.WarehouseRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
@@ -69,12 +72,13 @@ type ExpenseSummary struct {
Reference string `json:"reference_number"` Reference string `json:"reference_number"`
} }
func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService { func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService {
return &projectFlockKandangService{ return &projectFlockKandangService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoStockV2Svc: fifoStockV2Svc,
ExpenseRepo: expenseRepo, ExpenseRepo: expenseRepo,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo, ProductWarehouseRepo: productWarehouseRepo,
@@ -671,7 +675,91 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous
if availableQty < 0 { if availableQty < 0 {
availableQty = 0 availableQty = 0
} }
sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, productWarehouse.Id, nil)
if err != nil {
return 0, err
}
if sourceAvailable < availableQty {
availableQty = sourceAvailable
}
} }
return availableQty, nil return availableQty, nil
} }
func (s projectFlockKandangService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) {
if productWarehouseID == 0 || s.FifoStockV2Svc == nil {
return 0, nil
}
flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return 0, err
}
if strings.TrimSpace(flagGroupCode) == "" {
return 0, nil
}
gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: commonSvc.FifoStockV2Lane("STOCKABLE"),
AllocationPurpose: entity.StockAllocationPurposeConsume,
ProductWarehouseID: productWarehouseID,
AsOf: asOf,
Limit: 10000,
Tx: tx,
})
if err != nil {
return 0, err
}
total := 0.0
for _, row := range gatherRows {
if row.AvailableQuantity <= 0 {
continue
}
total += row.AvailableQuantity
}
return math.Max(total, 0), nil
}
func (s projectFlockKandangService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
selected := row{}
db := s.Repository.DB()
if tx != nil {
db = tx
}
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.lane = 'STOCKABLE'").
Where(`
EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = ?
AND f.flagable_type = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, productWarehouseID, entity.FlagableTypeProduct).
Order("fg.priority ASC, rr.id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
return strings.TrimSpace(selected.FlagGroupCode), nil
}
@@ -6,6 +6,7 @@ import (
"math" "math"
"strconv" "strconv"
"strings" "strings"
"time"
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto"
@@ -62,6 +63,7 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
SortBy: c.Query("sort_by", ""), SortBy: c.Query("sort_by", ""),
SortOrder: c.Query("sort_order", ""), SortOrder: c.Query("sort_order", ""),
Status: strings.TrimSpace(c.Query("status", "")),
} }
if area := c.QueryInt("area_id", 0); area > 0 { if area := c.QueryInt("area_id", 0); area > 0 {
@@ -272,10 +274,20 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
projectFlockId := c.QueryInt("project_flock_id", 0) projectFlockId := c.QueryInt("project_flock_id", 0)
kandangId := c.QueryInt("kandang_id", 0) kandangId := c.QueryInt("kandang_id", 0)
withPopulation := c.QueryBool("withpopulation", false) withPopulation := c.QueryBool("withpopulation", false)
recordDateRaw := strings.TrimSpace(c.Query("record_date", ""))
var recordDate *time.Time
if projectFlockId == 0 || kandangId == 0 { if projectFlockId == 0 || kandangId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id")
} }
if recordDateRaw != "" {
parsed, err := time.Parse("2006-01-02", recordDateRaw)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format")
}
utc := parsed.UTC()
recordDate = &utc
}
result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId)) result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId))
if err != nil { if err != nil {
@@ -300,6 +312,12 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse)
dtoResult.Warehouse = &mapped dtoResult.Warehouse = &mapped
} }
if isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil {
return serr
} else {
dtoResult.IsTransition = isTransition
dtoResult.IsLaying = isLaying
}
if withPopulation { if withPopulation {
population := dtoResult.AvailableQuantity population := dtoResult.AvailableQuantity
dtoResult.Population = &population dtoResult.Population = &population
@@ -3,6 +3,7 @@ package dto
import ( import (
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
@@ -25,17 +26,17 @@ type ProjectFlockRelationDTO struct {
type ProjectFlockListDTO struct { type ProjectFlockListDTO struct {
ProjectFlockRelationDTO ProjectFlockRelationDTO
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"` Category string `json:"category"`
StandardFcr *float64 `json:"standard_fcr,omitempty"` StandardFcr *float64 `json:"standard_fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"`
ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"` ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"` Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
} }
type KandangWithProjectFlockIdDTO struct { type KandangWithProjectFlockIdDTO struct {
@@ -203,7 +204,7 @@ func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 {
} }
week := 1 week := 1
if e.Category == string(utils.ProjectFlockCategoryLaying) { if e.Category == string(utils.ProjectFlockCategoryLaying) {
week = 18 week = config.LayingWeekStart()
} }
for _, detail := range e.ProductionStandard.ProductionStandardDetails { for _, detail := range e.ProductionStandard.ProductionStandardDetails {
if detail.Week == week && detail.StandardFCR != nil { if detail.Week == week && detail.StandardFCR != nil {
@@ -40,6 +40,8 @@ type ProjectFlockKandangDTO struct {
AvailableQuantity float64 `json:"available_quantity"` AvailableQuantity float64 `json:"available_quantity"`
Population *float64 `json:"population,omitempty"` Population *float64 `json:"population,omitempty"`
ChickInDate *time.Time `json:"chick_in_date,omitempty"` ChickInDate *time.Time `json:"chick_in_date,omitempty"`
IsTransition bool `json:"is_transition"`
IsLaying bool `json:"is_laying"`
} }
func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO {
@@ -17,6 +17,7 @@ import (
rProjectBudget "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectBudget "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils" utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -35,6 +36,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid
projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectflock.NewProjectFlockPopulationRepository(db) projectFlockPopulationRepo := rProjectflock.NewProjectFlockPopulationRepository(db)
recordingRepo := rRecording.NewRecordingRepository(db) recordingRepo := rRecording.NewRecordingRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db) projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db)
@@ -46,7 +48,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid
panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err))
} }
projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, approvalService, validate) projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, transferLayingRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ProjectflockRoutes(router, userService, projectflockService) ProjectflockRoutes(router, userService, projectflockService)
@@ -51,6 +51,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangID(ctx co
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Preload("ProjectChickin"). Preload("ProjectChickin").
Find(&records).Error Find(&records).Error
if err != nil { if err != nil {
@@ -87,6 +88,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangIDAndProd
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ? AND project_flock_populations.product_warehouse_id = ?", projectFlockKandangID, productWarehouseID). Where("project_chickins.project_flock_kandang_id = ? AND project_flock_populations.product_warehouse_id = ?", projectFlockKandangID, productWarehouseID).
Where("project_chickins.deleted_at IS NULL").
Find(&records).Error Find(&records).Error
if err != nil { if err != nil {
return nil, err return nil, err
@@ -99,8 +101,10 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Table("project_flock_populations"). Table("project_flock_populations").
Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS available_qty"). Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS available_qty").
Joins("JOIN product_warehouses pw ON project_flock_populations.product_warehouse_id = pw.id"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -111,9 +115,12 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI
func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) { func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) {
var total float64 var total float64
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}). Table("project_flock_populations").
Where("product_warehouse_id = ?", productWarehouseID). Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0)").
Select("COALESCE(SUM(total_qty - total_used_qty), 0)"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_flock_populations.product_warehouse_id = ?", productWarehouseID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -128,6 +135,8 @@ func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKand
Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS total_qty"). Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -145,6 +154,8 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKand
Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty"). Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -8,6 +8,7 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -110,6 +111,28 @@ func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *vali
AND pfk.kandang_id IN ? AND pfk.kandang_id IN ?
)`, params.KandangIds) )`, params.KandangIds)
} }
if params.Status != "" {
db = db.Where(`
EXISTS (
SELECT 1
FROM approvals latest_approval
WHERE latest_approval.approvable_type = ?
AND latest_approval.approvable_id = project_flocks.id
AND latest_approval.id = (
SELECT a2.id
FROM approvals a2
WHERE a2.approvable_type = ?
AND a2.approvable_id = project_flocks.id
ORDER BY a2.id DESC
LIMIT 1
)
AND LOWER(latest_approval.step_name) = LOWER(?)
)`,
utils.ApprovalWorkflowProjectFlock.String(),
utils.ApprovalWorkflowProjectFlock.String(),
params.Status,
)
}
db = r.applySearchFilters(db, params.Search) db = r.applySearchFilters(db, params.Search)
@@ -23,6 +23,7 @@ import (
pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
transferLayingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories"
purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils" utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -44,6 +45,8 @@ type ProjectflockService interface {
GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error)
GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error)
GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error)
GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error)
GetProjectFlockKandangTransferStateAtDate(ctx *fiber.Ctx, projectFlockKandangID uint, referenceDate *time.Time) (bool, bool, error)
GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error)
GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
@@ -64,6 +67,7 @@ type projectflockService struct {
PivotRepo repository.ProjectFlockKandangRepository PivotRepo repository.ProjectFlockKandangRepository
PopulationRepo repository.ProjectFlockPopulationRepository PopulationRepo repository.ProjectFlockPopulationRepository
RecordingRepo recordingRepo.RecordingRepository RecordingRepo recordingRepo.RecordingRepository
TransferLayingRepo transferLayingRepo.TransferLayingRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey approvalWorkflow approvalutils.ApprovalWorkflowKey
} }
@@ -85,6 +89,7 @@ func NewProjectflockService(
nonstockRepo nonstockRepository.NonstockRepository, nonstockRepo nonstockRepository.NonstockRepository,
populationRepo repository.ProjectFlockPopulationRepository, populationRepo repository.ProjectFlockPopulationRepository,
recordingRepo recordingRepo.RecordingRepository, recordingRepo recordingRepo.RecordingRepository,
transferLayingRepo transferLayingRepo.TransferLayingRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
validate *validator.Validate, validate *validator.Validate,
@@ -102,6 +107,7 @@ func NewProjectflockService(
PivotRepo: pivotRepo, PivotRepo: pivotRepo,
PopulationRepo: populationRepo, PopulationRepo: populationRepo,
RecordingRepo: recordingRepo, RecordingRepo: recordingRepo,
TransferLayingRepo: transferLayingRepo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowProjectFlock, approvalWorkflow: utils.ApprovalWorkflowProjectFlock,
} }
@@ -538,6 +544,70 @@ func (s projectflockService) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, p
return earliest, nil return earliest, nil
} }
func (s projectflockService) GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error) {
return s.GetProjectFlockKandangTransferStateAtDate(ctx, projectFlockKandangID, nil)
}
func (s projectflockService) GetProjectFlockKandangTransferStateAtDate(ctx *fiber.Ctx, projectFlockKandangID uint, referenceDate *time.Time) (bool, bool, error) {
if projectFlockKandangID == 0 || s.TransferLayingRepo == nil || s.PivotRepo == nil {
return false, false, nil
}
pfk, err := s.PivotRepo.GetByIDLight(ctx.Context(), projectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, false, nil
}
s.Log.Errorf("Failed to resolve project flock kandang %d for transfer state: %+v", projectFlockKandangID, err)
return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state")
}
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
var transfer *entity.LayingTransfer
switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx.Context(), projectFlockKandangID)
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx.Context(), projectFlockKandangID)
default:
return false, false, nil
}
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, false, nil
}
s.Log.Errorf("Failed to resolve transfer state for project flock kandang %d: %+v", projectFlockKandangID, err)
return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state")
}
if transfer == nil {
return false, false, nil
}
physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate)
if physicalMoveDate.IsZero() {
return false, false, nil
}
economicCutoffDate := physicalMoveDate
if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() {
economicCutoffDate = normalizeDateOnlyUTC(*transfer.EconomicCutoffDate)
} else if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() {
economicCutoffDate = normalizeDateOnlyUTC(*transfer.EffectiveMoveDate)
}
if economicCutoffDate.Before(physicalMoveDate) {
economicCutoffDate = physicalMoveDate
}
reference := normalizeDateOnlyUTC(time.Now().UTC())
if referenceDate != nil && !referenceDate.IsZero() {
reference = normalizeDateOnlyUTC(referenceDate.UTC())
}
isTransition := !reference.Before(physicalMoveDate) && reference.Before(economicCutoffDate)
isLaying := !reference.Before(economicCutoffDate)
return isTransition, isLaying, nil
}
func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) { func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) {
idStr = strings.TrimSpace(idStr) idStr = strings.TrimSpace(idStr)
projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) projectFlockIdStr = strings.TrimSpace(projectFlockIdStr)
@@ -579,6 +649,10 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt
return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid))
} }
func normalizeDateOnlyUTC(value time.Time) time.Time {
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
}
func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) {
if s.PopulationRepo == nil { if s.PopulationRepo == nil {
return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured") return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured")
@@ -20,6 +20,7 @@ type Query struct {
LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"`
Period int `query:"period" validate:"omitempty,number,gt=0"` Period int `query:"period" validate:"omitempty,number,gt=0"`
Category string `query:"category" validate:"omitempty"` Category string `query:"category" validate:"omitempty"`
Status string `query:"status" validate:"omitempty,oneof=Pengajuan Aktif Selesai"`
KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=transfer_to_laying"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=transfer_to_laying"`
} }
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/dto" productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/dto"
@@ -69,22 +70,26 @@ type RecordingWarehouseDTO struct {
} }
type RecordingRelationDTO struct { type RecordingRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
ProjectFlock RecordingProjectFlockDTO `json:"project_flock"` ProjectFlock RecordingProjectFlockDTO `json:"project_flock"`
RecordDatetime time.Time `json:"record_datetime"` RecordDatetime time.Time `json:"record_datetime"`
Day int `json:"day"` Day int `json:"day"`
TotalDepletionQty float64 `json:"total_depletion_qty"` TotalDepletionQty float64 `json:"total_depletion_qty"`
TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"` TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"`
CumDepletionRate float64 `json:"cum_depletion_rate"` CumDepletionRate float64 `json:"cum_depletion_rate"`
DepletionRate float64 `json:"depletion_rate"` DepletionRate float64 `json:"depletion_rate"`
CumIntake int `json:"cum_intake"` CumIntake int `json:"cum_intake"`
FcrValue float64 `json:"fcr_value"` FcrValue float64 `json:"fcr_value"`
HenDay float64 `json:"hen_day"` HenDay float64 `json:"hen_day"`
HenHouse float64 `json:"hen_house"` HenHouse float64 `json:"hen_house"`
FeedIntake float64 `json:"feed_intake"` FeedIntake float64 `json:"feed_intake"`
EggMass float64 `json:"egg_mass"` EggMass float64 `json:"egg_mass"`
EggWeight float64 `json:"egg_weight"` EggWeight float64 `json:"egg_weight"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"` PopulationCanChange bool `json:"population_can_change"`
TransferExecuted *bool `json:"transfer_executed,omitempty"`
IsTransition bool `json:"is_transition"`
IsLaying bool `json:"is_laying"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
} }
type RecordingListDTO struct { type RecordingListDTO struct {
@@ -228,22 +233,26 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
} }
return RecordingRelationDTO{ return RecordingRelationDTO{
Id: e.Id, Id: e.Id,
ProjectFlock: toRecordingProjectFlockDTO(e), ProjectFlock: toRecordingProjectFlockDTO(e),
RecordDatetime: e.RecordDatetime, RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day), Day: intValue(e.Day),
TotalDepletionQty: floatValue(e.TotalDepletionQty), TotalDepletionQty: floatValue(e.TotalDepletionQty),
TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty), TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty),
CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2), CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2),
DepletionRate: roundFloatValue(e.DepletionRate, 2), DepletionRate: roundFloatValue(e.DepletionRate, 2),
CumIntake: intValue(e.CumIntake), CumIntake: intValue(e.CumIntake),
FcrValue: floatValue(e.FcrValue), FcrValue: floatValue(e.FcrValue),
HenDay: floatValue(e.HenDay), HenDay: floatValue(e.HenDay),
HenHouse: floatValue(e.HenHouse), HenHouse: floatValue(e.HenHouse),
FeedIntake: floatValue(e.FeedIntake), FeedIntake: floatValue(e.FeedIntake),
EggMass: floatValue(e.EggMass), EggMass: floatValue(e.EggMass),
EggWeight: floatValue(e.EggWeight), EggWeight: floatValue(e.EggWeight),
Approval: latestApproval, PopulationCanChange: boolValueDefault(e.PopulationCanChange, true),
TransferExecuted: e.TransferExecuted,
IsTransition: boolValueDefault(e.IsTransition, false),
IsLaying: boolValueDefault(e.IsLaying, false),
Approval: latestApproval,
} }
} }
@@ -300,7 +309,7 @@ func recordingWeekValue(e entity.Recording) int {
} }
weekBase := 1 weekBase := 1
if isLayingRecording(e) { if isLayingRecording(e) {
weekBase = 18 weekBase = config.LayingWeekStart()
} }
return ((day - 1) / 7) + weekBase return ((day - 1) / 7) + weekBase
} }
@@ -449,6 +458,13 @@ func intValue(value *int) int {
return *value return *value
} }
func boolValueDefault(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO { func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO {
result := approvalDTO.ApprovalRelationDTO{} result := approvalDTO.ApprovalRelationDTO{}
@@ -2,6 +2,7 @@ package recordings
import ( import (
"fmt" "fmt"
"strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -24,8 +25,10 @@ import (
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/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"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
@@ -48,6 +51,8 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
chickinRepo := rChickin.NewChickinRepository(db) chickinRepo := rChickin.NewChickinRepository(db)
chickinDetailRepo := rChickin.NewChickinDetailRepository(db) chickinDetailRepo := rChickin.NewChickinDetailRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db) transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
layingTransferSourceRepo := rTransferLaying.NewLayingTransferSourceRepository(db)
layingTransferTargetRepo := rTransferLaying.NewLayingTransferTargetRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
@@ -61,6 +66,42 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
) )
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
if err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyTransferToLayingIn,
Table: "laying_transfer_targets",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register transfer to laying stockable workflow: %v", err))
}
}
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyTransferToLayingOut,
Table: "laying_transfers",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "source_usage_qty",
PendingQuantity: "source_pending_usage_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register transfer to laying usable workflow: %v", err))
}
}
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo) approvalService := commonSvc.NewApprovalService(approvalRepo)
@@ -84,6 +125,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
nonstockRepo, nonstockRepo,
projectFlockPopulationRepo, projectFlockPopulationRepo,
recordingRepo, recordingRepo,
transferLayingRepo,
approvalService, approvalService,
validate, validate,
) )
@@ -103,6 +145,20 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
fifoStockV2Service, fifoStockV2Service,
) )
transferLayingService := sTransferLaying.NewTransferLayingService(
transferLayingRepo,
layingTransferSourceRepo,
layingTransferTargetRepo,
projectFlockRepo,
projectFlockKandangRepo,
projectFlockPopulationRepo,
productWarehouseRepo,
warehouseRepo,
approvalService,
fifoStockV2Service,
validate,
)
recordingService := sRecording.NewRecordingService( recordingService := sRecording.NewRecordingService(
recordingRepo, recordingRepo,
projectFlockKandangRepo, projectFlockKandangRepo,
@@ -116,6 +172,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockService, projectFlockService,
chickinService, chickinService,
transferLayingRepo, transferLayingRepo,
transferLayingService,
validate, validate,
) )
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -71,6 +71,7 @@ type RecordingRepository interface {
GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error)
GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error)
GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error)
GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) ([]uint, error)
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
} }
@@ -874,6 +875,34 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID
return result, nil return result, nil
} }
func (r *RecordingRepositoryImpl) GetProjectFlockKandangIDsByPopulationWarehouseIDs(
ctx context.Context,
tx *gorm.DB,
productWarehouseIDs []uint,
) ([]uint, error) {
if len(productWarehouseIDs) == 0 {
return nil, nil
}
db := r.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var kandangIDs []uint
if err := db.Table("project_flock_populations pfp").
Select("DISTINCT pc.project_flock_kandang_id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pfp.product_warehouse_id IN ?", productWarehouseIDs).
Where("pfp.deleted_at IS NULL").
Where("pc.deleted_at IS NULL").
Pluck("pc.project_flock_kandang_id", &kandangIDs).Error; err != nil {
return nil, err
}
return kandangIDs, nil
}
func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 { if projectFlockKandangID == 0 {
return nil return nil
@@ -21,6 +21,7 @@ import (
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
@@ -56,6 +57,7 @@ type recordingService struct {
ProjectFlockSvc sProjectFlock.ProjectflockService ProjectFlockSvc sProjectFlock.ProjectflockService
ChickinSvc sChickin.ChickinService ChickinSvc sChickin.ChickinService
TransferLayingRepo rTransferLaying.TransferLayingRepository TransferLayingRepo rTransferLaying.TransferLayingRepository
TransferLayingSvc sTransferLaying.TransferLayingService
FifoStockV2Svc commonSvc.FifoStockV2Service FifoStockV2Svc commonSvc.FifoStockV2Service
StockLogRepo rStockLogs.StockLogRepository StockLogRepo rStockLogs.StockLogRepository
} }
@@ -73,6 +75,7 @@ func NewRecordingService(
projectFlockSvc sProjectFlock.ProjectflockService, projectFlockSvc sProjectFlock.ProjectflockService,
chickinSvc sChickin.ChickinService, chickinSvc sChickin.ChickinService,
transferLayingRepo rTransferLaying.TransferLayingRepository, transferLayingRepo rTransferLaying.TransferLayingRepository,
transferLayingSvc sTransferLaying.TransferLayingService,
validate *validator.Validate, validate *validator.Validate,
) RecordingService { ) RecordingService {
return &recordingService{ return &recordingService{
@@ -88,6 +91,7 @@ func NewRecordingService(
ProjectFlockSvc: projectFlockSvc, ProjectFlockSvc: projectFlockSvc,
ChickinSvc: chickinSvc, ChickinSvc: chickinSvc,
TransferLayingRepo: transferLayingRepo, TransferLayingRepo: transferLayingRepo,
TransferLayingSvc: transferLayingSvc,
FifoStockV2Svc: fifoStockV2Svc, FifoStockV2Svc: fifoStockV2Svc,
StockLogRepo: stockLogRepo, StockLogRepo: stockLogRepo,
} }
@@ -180,6 +184,15 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
totalChick := totalChickMap[recordings[i].ProjectFlockKandangId] totalChick := totalChickMap[recordings[i].ProjectFlockKandangId]
rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) rate := recordingutil.ComputeDepletionRate(prev, current, totalChick)
recordings[i].DepletionRate = &rate recordings[i].DepletionRate = &rate
populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i])
if stateErr != nil {
return nil, 0, stateErr
}
recordings[i].PopulationCanChange = boolPtr(populationCanChange)
recordings[i].TransferExecuted = boolPtr(transferExecuted)
recordings[i].IsTransition = boolPtr(isTransition)
recordings[i].IsLaying = boolPtr(isLaying)
} }
return recordings, total, nil return recordings, total, nil
} }
@@ -239,6 +252,16 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro
rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) rate := recordingutil.ComputeDepletionRate(prev, current, totalChick)
recording.DepletionRate = &rate recording.DepletionRate = &rate
} }
populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), recording)
if stateErr != nil {
return nil, stateErr
}
recording.PopulationCanChange = boolPtr(populationCanChange)
recording.TransferExecuted = boolPtr(transferExecuted)
recording.IsTransition = boolPtr(isTransition)
recording.IsLaying = boolPtr(isLaying)
return recording, nil return recording, nil
} }
@@ -293,10 +316,23 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
category := strings.ToUpper(pfk.ProjectFlock.Category) category := strings.ToUpper(pfk.ProjectFlock.Category)
isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying))
if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfk, recordTime); err != nil {
return nil, err
}
routePayload := buildRecordingRoutePayloadFromCreate(req) routePayload := buildRecordingRoutePayloadFromCreate(req)
if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime, routePayload); err != nil { if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime, routePayload); err != nil {
return nil, err return nil, err
} }
if routePayload.DepletionCount > 0 {
if err := s.ensureDepletionMutationAllowed(ctx, &entity.Recording{
ProjectFlockKandangId: req.ProjectFlockKandangId,
RecordDatetime: recordTime,
ProjectFlockKandang: pfk,
}, "buat"); err != nil {
return nil, err
}
}
if err := s.ProjectFlockSvc.EnsureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { if err := s.ProjectFlockSvc.EnsureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil {
return nil, err return nil, err
@@ -419,8 +455,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if err := s.reflowApplyRecordingDepletionsIn(ctx, tx, mappedDepletions); err != nil { if err := s.reflowApplyRecordingDepletionsIn(ctx, tx, mappedDepletions); err != nil {
return err return err
} }
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, createdRecording.ProjectFlockKandangId); err != nil { if err := s.resyncPopulationUsageForDepletions(ctx, tx, createdRecording.ProjectFlockKandangId, mappedDepletions); err != nil {
s.Log.Errorf("Failed to resync project flock population usage: %+v", err) s.Log.Errorf("Failed to resync depletion source population usage: %+v", err)
return err return err
} }
@@ -519,6 +555,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
var existingStocks []entity.RecordingStock var existingStocks []entity.RecordingStock
var existingDepletions []entity.RecordingDepletion var existingDepletions []entity.RecordingDepletion
var existingEggs []entity.RecordingEgg var existingEggs []entity.RecordingEgg
var mappedDepletions []entity.RecordingDepletion
note := recordingutil.RecordingNote("Edit", recordingEntity.Id) note := recordingutil.RecordingNote("Edit", recordingEntity.Id)
@@ -563,6 +600,12 @@ 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 {
return err
}
if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, nil); err != nil { if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, nil); err != nil {
return err return err
} }
@@ -582,7 +625,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err return err
} }
mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) mappedDepletions = recordingutil.MapDepletions(recordingEntity.Id, req.Depletions)
if len(mappedDepletions) > 0 { if len(mappedDepletions) > 0 {
if err := s.ensureDepletionWithinPopulation(ctx, tx, recordingEntity.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), sumDepletionQty(existingDepletions)); err != nil { if err := s.ensureDepletionWithinPopulation(ctx, tx, recordingEntity.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), sumDepletionQty(existingDepletions)); err != nil {
return err return err
@@ -673,8 +716,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return nil return nil
} }
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recordingEntity.ProjectFlockKandangId); err != nil { if err := s.resyncPopulationUsageForDepletions(ctx, tx, recordingEntity.ProjectFlockKandangId, append(existingDepletions, mappedDepletions...)); err != nil {
s.Log.Errorf("Failed to resync project flock population usage: %+v", err) s.Log.Errorf("Failed to resync depletion source population usage: %+v", err)
return err return err
} }
@@ -826,6 +869,11 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent
if action == entity.ApprovalActionRejected { if action == entity.ApprovalActionRejected {
note := recordingutil.RecordingNote("Reject", id) note := recordingutil.RecordingNote("Reject", id)
existingDepletions, err := s.Repository.ListDepletions(tx, id)
if err != nil {
s.Log.Errorf("Failed to list depletions before reject rollback %d: %+v", id, err)
return err
}
if err := s.reflowRollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { if err := s.reflowRollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil {
return err return err
} }
@@ -839,8 +887,8 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent
s.Log.Errorf("Failed to recompute recording metrics after reject %d: %+v", id, err) s.Log.Errorf("Failed to recompute recording metrics after reject %d: %+v", id, err)
return err return err
} }
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recording.ProjectFlockKandangId); err != nil { if err := s.resyncPopulationUsageForDepletions(ctx, tx, recording.ProjectFlockKandangId, existingDepletions); err != nil {
s.Log.Errorf("Failed to resync project flock population usage after reject %d: %+v", id, err) s.Log.Errorf("Failed to resync depletion source population usage after reject %d: %+v", id, err)
return err return err
} }
if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
@@ -896,6 +944,19 @@ 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
} }
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
}
}
if err := s.reflowRollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { if err := s.reflowRollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil {
return err return err
@@ -909,8 +970,8 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err return err
} }
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recording.ProjectFlockKandangId); err != nil { if err := s.resyncPopulationUsageForDepletions(ctx, tx, recording.ProjectFlockKandangId, existingDepletions); err != nil {
s.Log.Errorf("Failed to resync project flock population usage: %+v", err) s.Log.Errorf("Failed to resync depletion source population usage after delete: %+v", err)
return err return err
} }
@@ -923,6 +984,275 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
}) })
} }
func (s *recordingService) resolveRecordingCategory(ctx context.Context, recording *entity.Recording) (string, error) {
if recording == nil || recording.ProjectFlockKandangId == 0 {
return "", nil
}
if recording.ProjectFlockKandang != nil && recording.ProjectFlockKandang.ProjectFlock.Id != 0 {
return strings.ToUpper(strings.TrimSpace(recording.ProjectFlockKandang.ProjectFlock.Category)), nil
}
pfk, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recording.ProjectFlockKandangId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
return strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)), nil
}
func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, recording *entity.Recording) (bool, bool, bool, bool, *entity.LayingTransfer, time.Time, error) {
if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil {
return true, false, false, false, nil, time.Time{}, nil
}
category, err := s.resolveRecordingCategory(ctx, recording)
if err != nil {
s.Log.Errorf("Failed to resolve recording category for population mutation check (recording=%d): %+v", recording.Id, err)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
var transfer *entity.LayingTransfer
switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId)
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId)
default:
return true, false, false, false, nil, time.Time{}, nil
}
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, false, false, false, nil, time.Time{}, nil
}
s.Log.Errorf("Failed to resolve approved transfer for recording %d: %+v", recording.Id, err)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
if transfer == nil {
return true, false, false, false, nil, time.Time{}, nil
}
transferDate := transferPhysicalMoveDate(transfer)
if transferDate.IsZero() {
return true, false, false, false, transfer, transferDate, nil
}
transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero()
recordDate := normalizeDateOnlyUTC(recording.RecordDatetime)
_, economicCutoffDate := transferRecordingWindow(transfer)
isTransition := !recordDate.Before(transferDate) && recordDate.Before(economicCutoffDate)
isLaying := !recordDate.Before(economicCutoffDate)
populationCanChange := true
if category == strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) {
populationCanChange = !(transferExecuted && !recordDate.Before(transferDate))
if transferExecuted && !recordDate.Before(transferDate) {
hasTargetLayingRecording, checkErr := s.hasAnyRecordingOnTransferTargets(ctx, transfer)
if checkErr != nil {
s.Log.Errorf("Failed to resolve target laying recording state for transfer %d: %+v", transfer.Id, checkErr)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi status transisi recording")
}
if hasTargetLayingRecording {
isTransition = false
isLaying = true
} else {
today := normalizeDateOnlyUTC(time.Now().UTC())
if !today.Before(economicCutoffDate) {
isTransition = true
isLaying = false
}
}
}
}
return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil
}
func (s *recordingService) hasAnyRecordingOnTransferTargets(ctx context.Context, transfer *entity.LayingTransfer) (bool, error) {
if transfer == nil || transfer.Id == 0 {
return false, nil
}
targetIDs, err := s.transferTargetProjectFlockKandangIDs(ctx, transfer.Id)
if err != nil {
return false, err
}
if len(targetIDs) == 0 {
// Keep existing behavior for legacy or incomplete target mapping.
return true, nil
}
var count int64
err = s.Repository.DB().
WithContext(ctx).
Table("recordings").
Where("deleted_at IS NULL").
Where("project_flock_kandangs_id IN ?", targetIDs).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *recordingService) transferTargetProjectFlockKandangIDs(ctx context.Context, transferID uint) ([]uint, error) {
if transferID == 0 {
return nil, nil
}
var targetIDs []uint
err := s.Repository.DB().
WithContext(ctx).
Table("laying_transfer_targets").
Where("laying_transfer_id = ?", transferID).
Where("deleted_at IS NULL").
Pluck("target_project_flock_kandang_id", &targetIDs).Error
if err != nil {
return nil, err
}
return targetIDs, nil
}
func (s *recordingService) ensurePopulationMutationAllowed(ctx context.Context, recording *entity.Recording, operation string) error {
populationCanChange, _, _, _, transfer, transferDate, err := s.evaluatePopulationMutationState(ctx, recording)
if err != nil {
return err
}
if populationCanChange {
return nil
}
transferNumber := "-"
if transfer != nil && strings.TrimSpace(transfer.TransferNumber) != "" {
transferNumber = transfer.TransferNumber
}
recordDate := normalizeDateOnlyUTC(recording.RecordDatetime)
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Recording growing tanggal %s tidak dapat di%s karena transfer laying %s sudah dieksekusi sejak %s. Perubahan populasi tidak diizinkan.",
recordDate.Format("2006-01-02"),
operation,
transferNumber,
transferDate.Format("2006-01-02"),
),
)
}
func (s *recordingService) ensureDepletionMutationAllowed(ctx context.Context, recording *entity.Recording, operation string) error {
if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil {
return nil
}
category := ""
if recording.ProjectFlockKandang != nil && recording.ProjectFlockKandang.ProjectFlock.Id != 0 {
category = strings.ToUpper(strings.TrimSpace(recording.ProjectFlockKandang.ProjectFlock.Category))
} else {
pfk, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recording.ProjectFlockKandangId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
s.Log.Errorf("Failed to load project flock kandang %d for depletion guard: %+v", recording.ProjectFlockKandangId, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan deplesi")
}
category = strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
}
if !shouldGuardDepletionMutation(category) {
return nil
}
var (
transfer *entity.LayingTransfer
err error
)
transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
s.Log.Errorf("Failed to resolve transfer laying for depletion guard recording %d: %+v", recording.Id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan deplesi")
}
if transfer == nil || transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() {
return nil
}
recordDate := normalizeDateOnlyUTC(recording.RecordDatetime)
transferNumber := strings.TrimSpace(transfer.TransferNumber)
if transferNumber == "" {
transferNumber = "-"
}
executedDate := normalizeDateOnlyUTC(*transfer.ExecutedAt)
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Deplesi recording tanggal %s tidak dapat di%s karena transfer laying %s sudah dieksekusi pada %s. Setelah transfer dieksekusi, mutasi deplesi di kandang growing tidak diizinkan (termasuk backdate).",
recordDate.Format("2006-01-02"),
operation,
transferNumber,
executedDate.Format("2006-01-02"),
),
)
}
func shouldGuardDepletionMutation(category string) bool {
return strings.EqualFold(strings.TrimSpace(category), string(utils.ProjectFlockCategoryGrowing))
}
func (s *recordingService) tryAutoExecuteTransferForRecordingCreate(c *fiber.Ctx, pfk *entity.ProjectFlockKandang, recordTime time.Time) error {
if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil || s.TransferLayingSvc == nil {
return nil
}
ctx := c.Context()
recordDate := normalizeDateOnlyUTC(recordTime)
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
var (
transfer *entity.LayingTransfer
err error
)
switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id)
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, pfk.Id)
default:
return nil
}
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
s.Log.Errorf("Failed to resolve approved transfer for recording create (pfk=%d): %+v", pfk.Id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
if transfer == nil || (transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero()) {
return nil
}
physicalMoveDate := transferPhysicalMoveDate(transfer)
if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) {
return nil
}
if _, err := s.TransferLayingSvc.ExecuteWithBusinessDate(c, transfer.Id, recordDate); err != nil {
return err
}
return nil
}
func (s *recordingService) enforceTransferRecordingRoute( func (s *recordingService) enforceTransferRecordingRoute(
ctx context.Context, ctx context.Context,
pfk *entity.ProjectFlockKandang, pfk *entity.ProjectFlockKandang,
@@ -1062,21 +1392,31 @@ func buildRecordingRoutePayloadFromUpdate(req *validation.Update) recordingRoute
if req == nil { if req == nil {
return payload return payload
} }
for _, stock := range req.Stocks {
if stock.Qty > 0 { if req.Stocks != nil {
payload.StockCount++ for _, stock := range req.Stocks {
if stock.Qty > 0 {
payload.StockCount++
}
} }
} }
for _, depletion := range req.Depletions {
if depletion.Qty > 0 { if req.Depletions != nil {
payload.DepletionCount++ for _, depletion := range req.Depletions {
if depletion.Qty > 0 {
payload.DepletionCount++
}
} }
} }
for _, egg := range req.Eggs {
if egg.Qty > 0 { if req.Eggs != nil {
payload.EggCount++ for _, egg := range req.Eggs {
if egg.Qty > 0 {
payload.EggCount++
}
} }
} }
return payload return payload
} }
@@ -1119,6 +1459,11 @@ func normalizeDateOnlyUTC(value time.Time) time.Time {
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
} }
func boolPtr(value bool) *bool {
v := value
return &v
}
func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error {
idSet := make(map[uint]struct{}) idSet := make(map[uint]struct{})
@@ -1311,7 +1656,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
var feedIntake float64 var feedIntake float64
if remainingChick > 0 && usageInGrams > 0 { if remainingChick > 0 && usageInGrams > 0 {
feedIntake = (usageInGrams / remainingChick) * 1000 feedIntake = usageInGrams / remainingChick
updates["feed_intake"] = feedIntake updates["feed_intake"] = feedIntake
recording.FeedIntake = &feedIntake recording.FeedIntake = &feedIntake
} else { } else {
@@ -1731,10 +2076,7 @@ func (s *recordingService) reflowApplyRecordingStocks(
} }
s.logStockTrace("reflow_apply:done", *refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, actualUsage, actualPending)) s.logStockTrace("reflow_apply:done", *refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, actualUsage, actualPending))
logDecrease := actualUsage logDecrease := recordingStockRollbackQty(*refreshed)
if actualPending > 0 {
logDecrease += actualPending
}
if logDecrease > 0 && shouldWriteLog { if logDecrease > 0 && shouldWriteLog {
log := &entity.StockLog{ log := &entity.StockLog{
ProductWarehouseId: refreshed.ProductWarehouseId, ProductWarehouseId: refreshed.ProductWarehouseId,
@@ -1778,11 +2120,8 @@ func (s *recordingService) reflowResetRecordingStocks(
continue continue
} }
currentUsage := 0.0 rollbackQty := recordingStockRollbackQty(stock)
if stock.UsageQty != nil { s.logStockTrace("reflow_reset:start", stock, fmt.Sprintf("rollback_qty=%.3f", rollbackQty))
currentUsage = *stock.UsageQty
}
s.logStockTrace("reflow_reset:start", stock, "")
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err return err
@@ -1799,13 +2138,13 @@ func (s *recordingService) reflowResetRecordingStocks(
s.Log.Errorf("Failed to reflow FIFO v2 rollback for recording stock %d: %+v", stock.Id, err) s.Log.Errorf("Failed to reflow FIFO v2 rollback for recording stock %d: %+v", stock.Id, err)
return err return err
} }
s.logStockTrace("reflow_reset:done", stock, "") s.logStockTrace("reflow_reset:done", stock, fmt.Sprintf("rollback_qty=%.3f", rollbackQty))
if currentUsage > 0 && shouldWriteLog { if rollbackQty > 0 && shouldWriteLog {
log := &entity.StockLog{ log := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId, ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID, CreatedBy: actorID,
Increase: currentUsage, Increase: rollbackQty,
LoggableType: string(utils.StockLogTypeRecording), LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId, LoggableId: stock.RecordingId,
Notes: note, Notes: note,
@@ -1819,6 +2158,24 @@ func (s *recordingService) reflowResetRecordingStocks(
return nil return nil
} }
func recordingStockRollbackQty(stock entity.RecordingStock) float64 {
usage := 0.0
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
pending := 0.0
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
if usage < 0 {
usage = 0
}
if pending < 0 {
pending = 0
}
return usage + pending
}
type desiredStock struct { type desiredStock struct {
Usage float64 Usage float64
Pending float64 Pending float64
@@ -2037,15 +2394,10 @@ func (s *recordingService) reflowResetRecordingDepletionsOut(
return errors.New("stock log repository is not available") return errors.New("stock log repository is not available")
} }
logState := newRecordingStockLogState() logState := newRecordingStockLogState()
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
for _, depletion := range depletions { for _, depletion := range depletions {
if depletion.Id == 0 { if depletion.Id == 0 {
continue continue
} }
if err := stockAllocationRepo.ReleaseByUsable(ctx, fifo.UsableKeyRecordingDepletion.String(), depletion.Id, nil, nil); err != nil {
return err
}
s.logDepletionTrace("reflow_reset:start", depletion, "") s.logDepletionTrace("reflow_reset:start", depletion, "")
sourceWarehouseID := uint(0) sourceWarehouseID := uint(0)
@@ -2306,6 +2658,53 @@ func sumDepletionQty(items []entity.RecordingDepletion) float64 {
return total return total
} }
func (s *recordingService) resyncPopulationUsageForDepletions(
ctx context.Context,
tx *gorm.DB,
recordingProjectFlockKandangID uint,
depletions []entity.RecordingDepletion,
) error {
kandangIDs := map[uint]struct{}{}
if recordingProjectFlockKandangID != 0 {
kandangIDs[recordingProjectFlockKandangID] = struct{}{}
}
sourceWarehouseIDs := make([]uint, 0)
sourceWarehouseSeen := map[uint]struct{}{}
for _, dep := range depletions {
if dep.SourceProductWarehouseId == nil || *dep.SourceProductWarehouseId == 0 {
continue
}
pwID := *dep.SourceProductWarehouseId
if _, exists := sourceWarehouseSeen[pwID]; exists {
continue
}
sourceWarehouseSeen[pwID] = struct{}{}
sourceWarehouseIDs = append(sourceWarehouseIDs, pwID)
}
if len(sourceWarehouseIDs) > 0 {
sourceKandangIDs, err := s.Repository.GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx, tx, sourceWarehouseIDs)
if err != nil {
return err
}
for _, kandangID := range sourceKandangIDs {
if kandangID != 0 {
kandangIDs[kandangID] = struct{}{}
}
}
}
for kandangID := range kandangIDs {
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, kandangID); err != nil {
return err
}
}
return nil
}
func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error { func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error {
if projectFlockKandangId == 0 || newTotal <= 0 { if projectFlockKandangId == 0 || newTotal <= 0 {
return nil return nil
@@ -208,6 +208,28 @@ func (u *TransferLayingController) Execute(c *fiber.Ctx) error {
}) })
} }
func (u *TransferLayingController) Unexecute(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.TransferLayingService.Unexecute(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Unexecute transfer laying successfully",
Data: dto.ToTransferLayingDetailDTOWithSingleApproval(*result, result.LatestApproval),
})
}
func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error { func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error {
projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32) projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32)
if err != nil { if err != nil {
@@ -152,6 +152,46 @@ func ToLayingTransferSourceDTOs(sources []entity.LayingTransferSource) []LayingT
return result return result
} }
func toLayingTransferSourceDTOsFromTransfer(e entity.LayingTransfer) []LayingTransferSourceDTO {
if len(e.Sources) > 0 {
return ToLayingTransferSourceDTOs(e.Sources)
}
if e.SourceProjectFlockKandangId == nil || *e.SourceProjectFlockKandangId == 0 {
return []LayingTransferSourceDTO{}
}
displayQty := e.SourceRequestedQty
if e.SourceUsageQty > 0 {
displayQty = e.SourceUsageQty
}
pfkDTO := &ProjectFlockKandangWithKandangDTO{
Id: *e.SourceProjectFlockKandangId,
}
if e.SourceProjectFlockKandang != nil && e.SourceProjectFlockKandang.Id != 0 {
pfkDTO.KandangId = e.SourceProjectFlockKandang.KandangId
pfkDTO.ProjectFlockId = e.SourceProjectFlockKandang.ProjectFlockId
if e.SourceProjectFlockKandang.Kandang.Id != 0 {
kandangMapped := kandangDTO.ToKandangRelationDTO(e.SourceProjectFlockKandang.Kandang)
pfkDTO.Kandang = &kandangMapped
}
}
var pwDTO *productWarehouseDTO.ProductWarehouseRelationDTO
if e.SourceProductWarehouse != nil && e.SourceProductWarehouse.Id != 0 {
mapped := productWarehouseDTO.ToProductWarehouseRelationDTO(*e.SourceProductWarehouse)
pwDTO = &mapped
}
return []LayingTransferSourceDTO{
{
SourceProjectFlockKandang: pfkDTO,
Qty: displayQty,
ProductWarehouse: pwDTO,
},
}
}
func ToLayingTransferTargetDTO(target entity.LayingTransferTarget) LayingTransferTargetDTO { func ToLayingTransferTargetDTO(target entity.LayingTransferTarget) LayingTransferTargetDTO {
var pfkDTO *ProjectFlockKandangWithKandangDTO var pfkDTO *ProjectFlockKandangWithKandangDTO
if target.TargetProjectFlockKandang != nil && target.TargetProjectFlockKandang.Id != 0 { if target.TargetProjectFlockKandang != nil && target.TargetProjectFlockKandang.Id != 0 {
@@ -256,7 +296,7 @@ func ToTransferLayingDetailDTO(e entity.LayingTransfer, approvals []entity.Appro
return TransferLayingDetailDTO{ return TransferLayingDetailDTO{
TransferLayingListDTO: ToTransferLayingListDTO(e), TransferLayingListDTO: ToTransferLayingListDTO(e),
Sources: ToLayingTransferSourceDTOs(e.Sources), Sources: toLayingTransferSourceDTOsFromTransfer(e),
Targets: ToLayingTransferTargetDTOs(e.Targets), Targets: ToLayingTransferTargetDTOs(e.Targets),
Approval: latestApproval, Approval: latestApproval,
} }
@@ -278,7 +318,7 @@ func ToTransferLayingDetailDTOWithSingleApproval(e entity.LayingTransfer, approv
return TransferLayingDetailDTO{ return TransferLayingDetailDTO{
TransferLayingListDTO: ToTransferLayingListDTO(e), TransferLayingListDTO: ToTransferLayingListDTO(e),
Sources: ToLayingTransferSourceDTOs(e.Sources), Sources: toLayingTransferSourceDTOsFromTransfer(e),
Targets: ToLayingTransferTargetDTOs(e.Targets), Targets: ToLayingTransferTargetDTOs(e.Targets),
Approval: mappedApproval, Approval: mappedApproval,
} }
@@ -10,11 +10,11 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
"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"
rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
@@ -60,12 +60,12 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
// daftarin jadi usable // daftarin jadi usable
if err := fifoService.RegisterUsable(fifo.UsableConfig{ if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyTransferToLayingOut, Key: fifo.UsableKeyTransferToLayingOut,
Table: "laying_transfer_sources", Table: "laying_transfers",
Columns: fifo.UsableColumns{ Columns: fifo.UsableColumns{
ID: "id", ID: "id",
ProductWarehouseID: "product_warehouse_id", ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "usage_qty", UsageQuantity: "source_usage_qty",
PendingQuantity: "pending_usage_qty", PendingQuantity: "source_pending_usage_qty",
CreatedAt: "created_at", CreatedAt: "created_at",
}, },
OrderBy: []string{"created_at ASC", "id ASC"}, OrderBy: []string{"created_at ASC", "id ASC"},
@@ -91,7 +91,6 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
productWarehouseRepo, productWarehouseRepo,
warehouseRepo, warehouseRepo,
approvalService, approvalService,
fifoService,
fifoStockV2Service, fifoStockV2Service,
validate, validate,
) )
@@ -166,6 +166,9 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of
q = q.Offset(offset).Limit(limit). q = q.Offset(offset).Limit(limit).
Preload("FromProjectFlock"). Preload("FromProjectFlock").
Preload("ToProjectFlock"). Preload("ToProjectFlock").
Preload("SourceProjectFlockKandang").
Preload("SourceProjectFlockKandang.Kandang").
Preload("SourceProductWarehouse").
Preload("CreatedUser"). Preload("CreatedUser").
Preload("ExecutedUser"). Preload("ExecutedUser").
Preload("Sources"). Preload("Sources").
@@ -193,11 +196,12 @@ func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandang(ctx cont
var transfer entity.LayingTransfer var transfer entity.LayingTransfer
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Model(&entity.LayingTransfer{}). Model(&entity.LayingTransfer{}).
Joins("JOIN laying_transfer_sources lts ON lts.laying_transfer_id = laying_transfers.id AND lts.deleted_at IS NULL"). Distinct("laying_transfers.*").
Where("lts.source_project_flock_kandang_id = ?", sourceProjectFlockKandangID). Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = laying_transfers.id AND lts.deleted_at IS NULL").
Where("(laying_transfers.source_project_flock_kandang_id = ? OR lts.source_project_flock_kandang_id = ?)", sourceProjectFlockKandangID, sourceProjectFlockKandangID).
Where("laying_transfers.deleted_at IS NULL"). Where("laying_transfers.deleted_at IS NULL").
Where(`( Where(`(
SELECT a.action SELECT a.action
FROM approvals a FROM approvals a
WHERE a.approvable_type = ? WHERE a.approvable_type = ?
AND a.approvable_id = laying_transfers.id AND a.approvable_id = laying_transfers.id
@@ -2,15 +2,22 @@ package repository
import ( import (
"context" "context"
"errors"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm" "gorm.io/gorm"
) )
type LayingTransferTargetRepository interface { type LayingTransferTargetRepository interface {
repository.BaseRepository[entity.LayingTransferTarget] repository.BaseRepository[entity.LayingTransferTarget]
GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error) GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error)
GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error)
GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error)
CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error)
SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error
} }
type LayingTransferTargetRepositoryImpl struct { type LayingTransferTargetRepositoryImpl struct {
@@ -18,6 +25,11 @@ type LayingTransferTargetRepositoryImpl struct {
db *gorm.DB db *gorm.DB
} }
type TargetDownstreamConsumption struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint `gorm:"column:usable_id"`
}
func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository { func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository {
return &LayingTransferTargetRepositoryImpl{ return &LayingTransferTargetRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db), BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db),
@@ -36,3 +48,123 @@ func (r *LayingTransferTargetRepositoryImpl) GetByLayingTransferId(ctx context.C
} }
return targets, nil return targets, nil
} }
func (r *LayingTransferTargetRepositoryImpl) GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error) {
if len(targetIDs) == 0 {
return nil, nil
}
var rows []TargetDownstreamConsumption
err := r.db.WithContext(ctx).
Table("stock_allocations").
Select("usable_type, usable_id").
Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Where("stockable_id IN ?", targetIDs).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Group("usable_type, usable_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func (r *LayingTransferTargetRepositoryImpl) GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error) {
if targetProjectFlockKandangID == 0 {
return nil, nil
}
var earliest entity.Recording
query := r.db.WithContext(ctx).
Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID).
Where("deleted_at IS NULL")
if !sinceDate.IsZero() {
query = query.Where("record_datetime >= ?", sinceDate)
}
if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
d := earliest.RecordDatetime.UTC()
return &d, nil
}
func (r *LayingTransferTargetRepositoryImpl) CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error) {
if transferID == 0 || productWarehouseID == 0 {
return 0, nil
}
var count int64
err := r.db.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("product_warehouse_id = ?", productWarehouseID).
Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
Where("usable_id = ?", transferID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
func (r *LayingTransferTargetRepositoryImpl) SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
var populationIDs []uint
if err := r.db.WithContext(ctx).
Table("project_flock_populations pfp").
Select("pfp.id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
Pluck("pfp.id", &populationIDs).Error; err != nil {
return err
}
if len(populationIDs) == 0 {
return nil
}
type usageRow struct {
StockableID uint `gorm:"column:stockable_id"`
Used float64 `gorm:"column:used"`
}
var usageRows []usageRow
if err := r.db.WithContext(ctx).
Table("stock_allocations").
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("stockable_id IN ?", populationIDs).
Group("stockable_id").
Scan(&usageRows).Error; err != nil {
return err
}
if err := r.db.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id IN ?", populationIDs).
Update("total_used_qty", 0).Error; err != nil {
return err
}
for _, row := range usageRows {
if err := r.db.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", row.StockableID).
Update("total_used_qty", row.Used).Error; err != nil {
return err
}
}
return nil
}
@@ -28,6 +28,7 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying.
route.Delete("/:id", m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne) route.Delete("/:id", m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne)
route.Post("/approvals", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval) route.Post("/approvals", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval)
route.Post("/:id/execute", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Execute) route.Post("/:id/execute", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Execute)
route.Post("/:id/unexecute", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Unexecute)
route.Get("/project-flocks/:project_flock_id/available-qty", m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang) route.Get("/project-flocks/:project_flock_id/available-qty", m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang)
route.Get("/project-flocks/:project_flock_id/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang) route.Get("/project-flocks/:project_flock_id/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang)
} }
@@ -15,6 +15,11 @@ const (
transferLayingInFunctionCode = "TRANSFER_TO_LAYING_IN" transferLayingInFunctionCode = "TRANSFER_TO_LAYING_IN"
transferLayingStockableLane = "STOCKABLE" transferLayingStockableLane = "STOCKABLE"
transferLayingSourceTable = "laying_transfer_targets" transferLayingSourceTable = "laying_transfer_targets"
transferLayingOutFunctionCode = "TRANSFER_TO_LAYING_OUT"
transferLayingUsableLane = "USABLE"
transferLayingUsableSourceTable = "laying_transfers"
transferLayingLegacyUsableSourceTable = "laying_transfer_sources"
) )
func reflowTransferLayingScope( func reflowTransferLayingScope(
@@ -85,3 +90,90 @@ func resolveTransferLayingFlagGroupByProductWarehouse(ctx context.Context, tx *g
return strings.TrimSpace(selected.FlagGroupCode), nil return strings.TrimSpace(selected.FlagGroupCode), nil
} }
type transferLayingUsableRouteRule struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
SourceTable string `gorm:"column:source_table"`
}
func resolveTransferLayingUsableFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
rows := make([]transferLayingUsableRouteRule, 0)
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code, rr.source_table").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.lane = ?", transferLayingUsableLane).
Where("rr.function_code = ?", transferLayingOutFunctionCode).
Where(`
EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = ?
AND f.flagable_type = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, productWarehouseID, entity.FlagableTypeProduct).
Order("rr.id ASC").
Find(&rows).Error
if err != nil {
return "", err
}
return validateTransferLayingUsableRouteRules(rows, productWarehouseID)
}
func validateTransferLayingUsableRouteRules(rows []transferLayingUsableRouteRule, productWarehouseID uint) (string, error) {
if len(rows) == 0 {
return "", fmt.Errorf(
"konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT tidak ditemukan untuk source warehouse %d",
productWarehouseID,
)
}
var selectedFlagGroup string
hasHeaderRule := false
hasLegacyRule := false
for _, row := range rows {
sourceTable := strings.ToLower(strings.TrimSpace(row.SourceTable))
flagGroupCode := strings.TrimSpace(row.FlagGroupCode)
switch sourceTable {
case transferLayingUsableSourceTable:
if flagGroupCode == "" {
return "", fmt.Errorf("konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT memiliki flag_group_code kosong")
}
hasHeaderRule = true
if selectedFlagGroup == "" {
selectedFlagGroup = flagGroupCode
continue
}
if selectedFlagGroup != flagGroupCode {
return "", fmt.Errorf(
"konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT ambigu untuk source warehouse %d",
productWarehouseID,
)
}
case transferLayingLegacyUsableSourceTable:
hasLegacyRule = true
}
}
if hasLegacyRule {
return "", fmt.Errorf(
"konfigurasi FIFO v2 legacy untuk TRANSFER_TO_LAYING_OUT masih aktif (source_table=%s)",
transferLayingLegacyUsableSourceTable,
)
}
if !hasHeaderRule {
return "", fmt.Errorf(
"konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT aktif untuk source_table=%s tidak ditemukan",
transferLayingUsableSourceTable,
)
}
return selectedFlagGroup, nil
}
@@ -0,0 +1,56 @@
package service
import (
"strings"
"testing"
)
func TestValidateTransferLayingUsableRouteRules(t *testing.T) {
t.Run("valid header rule", func(t *testing.T) {
flagGroup, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{
{FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable},
}, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if flagGroup != "AYAM" {
t.Fatalf("unexpected flag group: %s", flagGroup)
}
})
t.Run("missing usable header rule", func(t *testing.T) {
_, err := validateTransferLayingUsableRouteRules(nil, 10)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(strings.ToLower(err.Error()), "tidak ditemukan") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("legacy rule still active", func(t *testing.T) {
_, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{
{FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable},
{FlagGroupCode: "AYAM", SourceTable: transferLayingLegacyUsableSourceTable},
}, 10)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(strings.ToLower(err.Error()), "legacy") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("ambiguous active header rules", func(t *testing.T) {
_, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{
{FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable},
{FlagGroupCode: "PAKAN", SourceTable: transferLayingUsableSourceTable},
}, 10)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(strings.ToLower(err.Error()), "ambigu") {
t.Fatalf("unexpected error: %v", err)
}
})
}
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,7 @@ type Create struct {
TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"` TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"`
SourceProjectFlockId uint `json:"source_project_flock_id" validate:"required"` SourceProjectFlockId uint `json:"source_project_flock_id" validate:"required"`
TargetProjectFlockId uint `json:"target_project_flock_id" validate:"required"` TargetProjectFlockId uint `json:"target_project_flock_id" validate:"required"`
SourceKandangs []SourceKandangDetail `json:"source_kandangs" validate:"required,min=1,dive,required"` SourceKandangs []SourceKandangDetail `json:"source_kandangs" validate:"required,min=1,max=1,dive,required"`
TargetKandangs []TargetKandangDetail `json:"target_kandangs" validate:"required,min=1,dive,required"` TargetKandangs []TargetKandangDetail `json:"target_kandangs" validate:"required,min=1,dive,required"`
Reason string `json:"reason" validate:"omitempty,max=1000"` Reason string `json:"reason" validate:"omitempty,max=1000"`
} }
@@ -23,7 +23,7 @@ type Update struct {
TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"` TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"`
SourceProjectFlockId uint `json:"source_project_flock_id" validate:"required"` SourceProjectFlockId uint `json:"source_project_flock_id" validate:"required"`
TargetProjectFlockId uint `json:"target_project_flock_id" validate:"required"` TargetProjectFlockId uint `json:"target_project_flock_id" validate:"required"`
SourceKandangs []SourceKandangDetail `json:"source_kandangs" validate:"required,min=1,dive,required"` SourceKandangs []SourceKandangDetail `json:"source_kandangs" validate:"required,min=1,max=1,dive,required"`
TargetKandangs []TargetKandangDetail `json:"target_kandangs" validate:"required,min=1,dive,required"` TargetKandangs []TargetKandangDetail `json:"target_kandangs" validate:"required,min=1,dive,required"`
Reason string `json:"reason" validate:"omitempty,max=1000"` Reason string `json:"reason" validate:"omitempty,max=1000"`
} }
@@ -13,6 +13,7 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
@@ -380,12 +381,13 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
} }
} }
weekBase := 1 weekBase := 1
if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) { isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying))
weekBase = 18 if isLayingCategory {
weekBase = config.LayingWeekStart()
} }
if req.Week < weekBase { if req.Week < weekBase {
if weekBase == 18 { if isLayingCategory {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
} }
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
} }
@@ -399,8 +401,8 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence")
} }
if latestWeek == 0 && req.Week != weekBase { if latestWeek == 0 && req.Week != weekBase {
if weekBase == 18 { if isLayingCategory {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
} }
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
} }
@@ -474,7 +476,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
}); err != nil { }); err != nil {
s.Log.Errorf("Failed to create uniformity: %+v", err) s.Log.Errorf("Failed to create uniformity: %+v", err)
return nil, err return nil, err
} }
if s.DocumentSvc != nil { if s.DocumentSvc != nil {
actorIDCopy := actorID actorIDCopy := actorID
@@ -575,12 +577,13 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
} }
} }
weekBase := 1 weekBase := 1
if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) { isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying))
weekBase = 18 if isLayingCategory {
weekBase = config.LayingWeekStart()
} }
if targetWeek < weekBase { if targetWeek < weekBase {
if weekBase == 18 { if isLayingCategory {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
} }
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
} }
@@ -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
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
@@ -256,11 +257,11 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation.
const ( const (
recordsPerWeek = 7 recordsPerWeek = 7
defaultStartWoa = 18
defaultStdBw = 1951 defaultStdBw = 1951
defaultBw = 0 defaultBw = 0
defaultUniformText = "90% up" defaultUniformText = "90% up"
) )
defaultStartWoa := config.LayingWeekStart()
if params.Limit <= 0 { if params.Limit <= 0 {
params.Limit = 10 params.Limit = 10
+184 -9
View File
@@ -14,9 +14,17 @@ type FlagType string
type FlagGroup string type FlagGroup string
type ProductFlagOption struct {
Flag FlagType `json:"flag"`
SubFlags []FlagType `json:"sub_flags"`
AllowWithoutSubFlag bool `json:"allow_without_sub_flag"`
}
const ( const (
FlagIsActive FlagType = "IS_ACTIVE" FlagIsActive FlagType = "IS_ACTIVE"
FlagAyam FlagType = "AYAM"
FlagDOC FlagType = "DOC" FlagDOC FlagType = "DOC"
FlagPullet FlagType = "PULLET" FlagPullet FlagType = "PULLET"
FlagLayer FlagType = "LAYER" FlagLayer FlagType = "LAYER"
@@ -36,11 +44,13 @@ const (
FlagAyamMati FlagType = "AYAM-MATI" FlagAyamMati FlagType = "AYAM-MATI"
//flag telur //flag telur
FlagTelur FlagType = "TELUR" FlagTelur FlagType = "TELUR"
FlagTelurUtuh FlagType = "TELUR-UTUH" FlagTelurUtuh FlagType = "TELUR-UTUH"
FlagTelurPecah FlagType = "TELUR-PECAH" FlagTelurPecah FlagType = "TELUR-PECAH"
FlagTelurPutih FlagType = "TELUR-PUTIH" FlagTelurPutih FlagType = "TELUR-PUTIH"
FlagTelurRetak FlagType = "TELUR-RETAK" FlagTelurRetak FlagType = "TELUR-RETAK"
FlagTelurPapacal FlagType = "TELUR-PAPACAL"
FlagTelurJumbo FlagType = "TELUR-JUMBO"
) )
const ( const (
@@ -50,9 +60,10 @@ const (
var flagGroupOptions = map[FlagGroup][]FlagType{ var flagGroupOptions = map[FlagGroup][]FlagType{
FlagGroupProduct: { FlagGroupProduct: {
FlagDOC, FlagAyam,
FlagPullet, FlagAyamAfkir,
FlagLayer, FlagAyamCulling,
FlagAyamMati,
FlagPakan, FlagPakan,
FlagPreStarter, FlagPreStarter,
FlagStarter, FlagStarter,
@@ -61,12 +72,75 @@ var flagGroupOptions = map[FlagGroup][]FlagType{
FlagObat, FlagObat,
FlagVitamin, FlagVitamin,
FlagKimia, FlagKimia,
FlagTelur,
FlagTelurUtuh,
FlagTelurPecah,
FlagTelurPutih,
FlagTelurRetak,
FlagTelurPapacal,
FlagTelurJumbo,
}, },
FlagGroupNonstock: { FlagGroupNonstock: {
FlagEkspedisi, FlagEkspedisi,
}, },
} }
var productMainFlags = []FlagType{
FlagAyam,
FlagPakan,
FlagOVK,
FlagTelur,
}
var productSubFlagsByFlag = map[FlagType][]FlagType{
FlagAyam: {
FlagAyamAfkir,
FlagAyamCulling,
FlagAyamMati,
},
FlagPakan: {
FlagPreStarter,
FlagStarter,
FlagFinisher,
},
FlagOVK: {
FlagObat,
FlagVitamin,
FlagKimia,
},
FlagTelur: {
FlagTelurUtuh,
FlagTelurPutih,
FlagTelurRetak,
FlagTelurPecah,
FlagTelurPapacal,
FlagTelurJumbo,
},
}
var productSubFlagToFlag = func() map[FlagType]FlagType {
out := make(map[FlagType]FlagType)
for flag, subFlags := range productSubFlagsByFlag {
for _, subFlag := range subFlags {
out[subFlag] = flag
}
}
return out
}()
var productAllowWithoutSubFlagByFlag = map[FlagType]bool{
FlagAyam: true,
FlagPakan: false,
FlagOVK: false,
FlagTelur: false,
}
var legacyFlagTypeAliases = map[FlagType]FlagType{
FlagDOC: FlagAyam,
FlagPullet: FlagAyam,
FlagLayer: FlagAyam,
}
var allFlagTypes = func() map[FlagType]struct{} { var allFlagTypes = func() map[FlagType]struct{} {
m := map[FlagType]struct{}{ m := map[FlagType]struct{}{
FlagIsActive: {}, FlagIsActive: {},
@@ -83,6 +157,102 @@ func AllFlagTypes() map[FlagType]struct{} {
return allFlagTypes return allFlagTypes
} }
func canonicalizeFlagType(flag FlagType) FlagType {
if canonical, ok := legacyFlagTypeAliases[flag]; ok {
return canonical
}
return flag
}
func CanonicalFlagType(v string) FlagType {
normalized := FlagType(strings.ToUpper(strings.TrimSpace(v)))
if normalized == "" {
return ""
}
return canonicalizeFlagType(normalized)
}
func LegacyFlagTypeAliases() map[FlagType]FlagType {
out := make(map[FlagType]FlagType, len(legacyFlagTypeAliases))
for legacy, canonical := range legacyFlagTypeAliases {
out[legacy] = canonical
}
return out
}
func ProductMainFlags() []FlagType {
out := make([]FlagType, len(productMainFlags))
copy(out, productMainFlags)
return out
}
func ProductSubFlagsByFlag() map[FlagType][]FlagType {
out := make(map[FlagType][]FlagType, len(productSubFlagsByFlag))
for flag, subFlags := range productSubFlagsByFlag {
dup := make([]FlagType, len(subFlags))
copy(dup, subFlags)
out[flag] = dup
}
return out
}
func ProductSubFlagToFlag() map[FlagType]FlagType {
out := make(map[FlagType]FlagType, len(productSubFlagToFlag))
for subFlag, flag := range productSubFlagToFlag {
out[subFlag] = flag
}
return out
}
func ProductFlagOptions() []ProductFlagOption {
result := make([]ProductFlagOption, 0, len(productMainFlags))
for _, flag := range productMainFlags {
subFlags := productSubFlagsByFlag[flag]
dup := make([]FlagType, len(subFlags))
copy(dup, subFlags)
result = append(result, ProductFlagOption{
Flag: flag,
SubFlags: dup,
AllowWithoutSubFlag: productAllowWithoutSubFlagByFlag[flag],
})
}
return result
}
func ProductFlagAllowWithoutSubFlag(flag FlagType) bool {
canonical := canonicalizeFlagType(flag)
allow, ok := productAllowWithoutSubFlagByFlag[canonical]
if !ok {
return false
}
return allow
}
func IsProductMainFlag(flag FlagType) bool {
canonical := canonicalizeFlagType(flag)
for _, f := range productMainFlags {
if f == canonical {
return true
}
}
return false
}
func IsValidProductSubFlag(flag FlagType, subFlag FlagType) bool {
canonicalFlag := canonicalizeFlagType(flag)
canonicalSubFlag := canonicalizeFlagType(subFlag)
allowedSubFlags, ok := productSubFlagsByFlag[canonicalFlag]
if !ok {
return false
}
for _, allowed := range allowedSubFlags {
if allowed == canonicalSubFlag {
return true
}
}
return false
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// WarehouseType // WarehouseType
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -621,7 +791,11 @@ const (
// ------------------------------------------------------------------- // -------------------------------------------------------------------
func IsValidFlagType(v string) bool { func IsValidFlagType(v string) bool {
_, ok := allFlagTypes[FlagType(strings.ToUpper(strings.TrimSpace(v)))] flag := FlagType(strings.ToUpper(strings.TrimSpace(v)))
if _, ok := allFlagTypes[flag]; ok {
return true
}
_, ok := legacyFlagTypeAliases[flag]
return ok return ok
} }
@@ -667,6 +841,7 @@ func NormalizeFlagTypes(flags []string) []FlagType {
if normalized == "" { if normalized == "" {
continue continue
} }
normalized = canonicalizeFlagType(normalized)
if _, exists := seen[normalized]; exists { if _, exists := seen[normalized]; exists {
continue continue
} }
+193 -192
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -13,31 +14,31 @@ import (
) )
type warnLogger interface { type warnLogger interface {
Warnf(format string, args ...any) Warnf(format string, args ...any)
} }
type productWarehouseExistsRepo interface { type productWarehouseExistsRepo interface {
ExistsByID(ctx context.Context, id uint) (bool, error) ExistsByID(ctx context.Context, id uint) (bool, error)
} }
type recordingValidationRepo interface { type recordingValidationRepo interface {
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
} }
func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error { func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error {
if repo == nil || len(ids) == 0 { if repo == nil || len(ids) == 0 {
return nil return nil
} }
for _, id := range ids { for _, id := range ids {
ok, err := repo.ExistsByID(ctx, id) ok, err := repo.ExistsByID(ctx, id)
if err != nil { if err != nil {
return err return err
} }
if !ok { if !ok {
return fmt.Errorf("product warehouse %d not found", id) return fmt.Errorf("product warehouse %d not found", id)
} }
} }
return nil return nil
} }
func EnsureProductWarehousesByFlags(ctx context.Context, repo recordingValidationRepo, ids []uint, flags []string, label string) error { func EnsureProductWarehousesByFlags(ctx context.Context, repo recordingValidationRepo, ids []uint, flags []string, label string) error {
@@ -82,212 +83,212 @@ func EnsureProductWarehousesByFlagsForItems[T any](
} }
func ComputeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 { func ComputeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 {
base := 0.0 base := 0.0
if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 { if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 {
base = *prevRecording.TotalChickQty base = *prevRecording.TotalChickQty
} else if totalChick > 0 { } else if totalChick > 0 {
base = float64(totalChick) + currentDepletion base = float64(totalChick) + currentDepletion
} }
if base <= 0 { if base <= 0 {
return 0 return 0
} }
return (currentDepletion / base) * 100 return (currentDepletion / base) * 100
} }
func AttachLatestApprovals(ctx context.Context, items []entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error { func AttachLatestApprovals(ctx context.Context, items []entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error {
if len(items) == 0 || approvalSvc == nil { if len(items) == 0 || approvalSvc == nil {
return nil return nil
} }
ids := make([]uint, 0, len(items)) ids := make([]uint, 0, len(items))
visited := make(map[uint]struct{}, len(items)) visited := make(map[uint]struct{}, len(items))
for _, item := range items { for _, item := range items {
if item.Id == 0 { if item.Id == 0 {
continue continue
} }
if _, ok := visited[item.Id]; ok { if _, ok := visited[item.Id]; ok {
continue continue
} }
visited[item.Id] = struct{}{} visited[item.Id] = struct{}{}
ids = append(ids, item.Id) ids = append(ids, item.Id)
} }
if len(ids) == 0 { if len(ids) == 0 {
return nil return nil
} }
latestMap, err := approvalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB { latestMap, err := approvalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser") return db.Preload("ActionUser")
}) })
if err != nil { if err != nil {
if logger != nil { if logger != nil {
logger.Warnf("Unable to load latest approvals for recordings: %+v", err) logger.Warnf("Unable to load latest approvals for recordings: %+v", err)
} }
return nil return nil
} }
if len(latestMap) == 0 { if len(latestMap) == 0 {
return nil return nil
} }
for i := range items { for i := range items {
if items[i].Id == 0 { if items[i].Id == 0 {
continue continue
} }
if approval, ok := latestMap[items[i].Id]; ok { if approval, ok := latestMap[items[i].Id]; ok {
items[i].LatestApproval = approval items[i].LatestApproval = approval
} }
} }
return nil return nil
} }
func AttachLatestApproval(ctx context.Context, item *entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error { func AttachLatestApproval(ctx context.Context, item *entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error {
if item == nil || item.Id == 0 || approvalSvc == nil { if item == nil || item.Id == 0 || approvalSvc == nil {
return nil return nil
} }
latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB { latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser") return db.Preload("ActionUser")
}) })
if err != nil { if err != nil {
if logger != nil { if logger != nil {
logger.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err) logger.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err)
} }
return nil return nil
} }
item.LatestApproval = latest item.LatestApproval = latest
return nil return nil
} }
type productionStandardValues struct { type productionStandardValues struct {
HenDay *float64 HenDay *float64
HenHouse *float64 HenHouse *float64
FeedIntake *float64 FeedIntake *float64
MaxDepletion *float64 MaxDepletion *float64
EggMass *float64 EggMass *float64
EggWeight *float64 EggWeight *float64
} }
func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, logger warnLogger, items ...*entity.Recording) error { func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, logger warnLogger, items ...*entity.Recording) error {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
} }
type standardKey struct { type standardKey struct {
standardID uint standardID uint
week int week int
} }
type standardCacheEntry struct { type standardCacheEntry struct {
values productionStandardValues values productionStandardValues
fcr *float64 fcr *float64
} }
if db == nil { if db == nil {
return nil return nil
} }
standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db)
cache := make(map[standardKey]standardCacheEntry, len(items)) cache := make(map[standardKey]standardCacheEntry, len(items))
standardIDs := make(map[uint]struct{}, len(items)) standardIDs := make(map[uint]struct{}, len(items))
for _, item := range items { for _, item := range items {
if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 {
continue continue
} }
if item.ProjectFlockKandang.ProjectFlock.ProductionStandardId > 0 { if item.ProjectFlockKandang.ProjectFlock.ProductionStandardId > 0 {
standardIDs[item.ProjectFlockKandang.ProjectFlock.ProductionStandardId] = struct{}{} standardIDs[item.ProjectFlockKandang.ProjectFlock.ProductionStandardId] = struct{}{}
} }
} }
standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs)) standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs))
growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs)) growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs))
for standardID := range standardIDs { for standardID := range standardIDs {
details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID) details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil { if err != nil {
if warnOnly { if warnOnly {
if logger != nil { if logger != nil {
logger.Warnf("Unable to preload production standard detail for standard %d: %+v", standardID, err) logger.Warnf("Unable to preload production standard detail for standard %d: %+v", standardID, err)
} }
} else { } else {
return err return err
} }
continue continue
} }
detailMap := make(map[int]*entity.ProductionStandardDetail, len(details)) detailMap := make(map[int]*entity.ProductionStandardDetail, len(details))
for i := range details { for i := range details {
detail := details[i] detail := details[i]
detailMap[detail.Week] = &detail detailMap[detail.Week] = &detail
} }
standardDetailByStd[standardID] = detailMap standardDetailByStd[standardID] = detailMap
growths, err := growthDetailRepo.GetByProductionStandardID(ctx, standardID) growths, err := growthDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil { if err != nil {
if warnOnly { if warnOnly {
if logger != nil { if logger != nil {
logger.Warnf("Unable to preload standard growth detail for standard %d: %+v", standardID, err) logger.Warnf("Unable to preload standard growth detail for standard %d: %+v", standardID, err)
} }
} else { } else {
return err return err
} }
continue continue
} }
growthMap := make(map[int]*entity.StandardGrowthDetail, len(growths)) growthMap := make(map[int]*entity.StandardGrowthDetail, len(growths))
for i := range growths { for i := range growths {
growth := growths[i] growth := growths[i]
growthMap[growth.Week] = &growth growthMap[growth.Week] = &growth
} }
growthDetailByStd[standardID] = growthMap growthDetailByStd[standardID] = growthMap
} }
for _, item := range items { for _, item := range items {
if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 {
continue continue
} }
standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId
if standardID == 0 { if standardID == 0 {
continue continue
} }
week := RecordingWeekValue(*item) week := RecordingWeekValue(*item)
cacheKey := standardKey{standardID: standardID, week: week} cacheKey := standardKey{standardID: standardID, week: week}
if cached, ok := cache[cacheKey]; ok { if cached, ok := cache[cacheKey]; ok {
applyProductionStandardValues(item, cached.values, cached.fcr) applyProductionStandardValues(item, cached.values, cached.fcr)
continue continue
} }
values := productionStandardValues{} values := productionStandardValues{}
var fcr *float64 var fcr *float64
if detailMap, ok := standardDetailByStd[standardID]; ok { if detailMap, ok := standardDetailByStd[standardID]; ok {
if detail, ok := detailMap[week]; ok { if detail, ok := detailMap[week]; ok {
values.HenDay = detail.TargetHenDayProduction values.HenDay = detail.TargetHenDayProduction
values.HenHouse = detail.TargetHenHouseProduction values.HenHouse = detail.TargetHenHouseProduction
values.EggMass = detail.TargetEggMass values.EggMass = detail.TargetEggMass
values.EggWeight = detail.TargetEggWeight values.EggWeight = detail.TargetEggWeight
fcr = detail.StandardFCR fcr = detail.StandardFCR
} }
} }
if growthMap, ok := growthDetailByStd[standardID]; ok { if growthMap, ok := growthDetailByStd[standardID]; ok {
if growth, ok := growthMap[week]; ok { if growth, ok := growthMap[week]; ok {
values.FeedIntake = growth.FeedIntake values.FeedIntake = growth.FeedIntake
values.MaxDepletion = growth.MaxDepletion values.MaxDepletion = growth.MaxDepletion
} }
} }
cache[cacheKey] = standardCacheEntry{values: values, fcr: fcr} cache[cacheKey] = standardCacheEntry{values: values, fcr: fcr}
applyProductionStandardValues(item, values, fcr) applyProductionStandardValues(item, values, fcr)
} }
return nil return nil
} }
func applyProductionStandardValues(item *entity.Recording, values productionStandardValues, fcr *float64) { func applyProductionStandardValues(item *entity.Recording, values productionStandardValues, fcr *float64) {
item.StandardHenDay = values.HenDay item.StandardHenDay = values.HenDay
item.StandardHenHouse = values.HenHouse item.StandardHenHouse = values.HenHouse
item.StandardFeedIntake = values.FeedIntake item.StandardFeedIntake = values.FeedIntake
item.StandardMaxDepletion = values.MaxDepletion item.StandardMaxDepletion = values.MaxDepletion
item.StandardEggMass = values.EggMass item.StandardEggMass = values.EggMass
item.StandardEggWeight = values.EggWeight item.StandardEggWeight = values.EggWeight
item.StandardFcr = fcr item.StandardFcr = fcr
} }
func RecordingWeekValue(e entity.Recording) int { func RecordingWeekValue(e entity.Recording) int {
@@ -297,7 +298,7 @@ func RecordingWeekValue(e entity.Recording) int {
} }
weekBase := 1 weekBase := 1
if IsLayingRecording(e) { if IsLayingRecording(e) {
weekBase = 18 weekBase = config.LayingWeekStart()
} }
return ((day - 1) / 7) + weekBase return ((day - 1) / 7) + weekBase
} }
+76
View File
@@ -0,0 +1,76 @@
-- Audit orphan stock_allocations (ACTIVE + CONSUME)
-- Usage:
-- psql -U app_lti_user -d db_lti_erp -f scripts/sql/orphan_allocations_audit.sql
\pset pager off
WITH active_alloc AS (
SELECT id, usable_type, usable_id, stockable_type, stockable_id, product_warehouse_id, qty
FROM stock_allocations
WHERE status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
AND deleted_at IS NULL
),
orphan AS (
SELECT a.*
FROM active_alloc a
WHERE
(a.usable_type = 'ADJUSTMENT_OUT' AND NOT EXISTS (SELECT 1 FROM adjustment_stocks ad WHERE ad.id = a.usable_id))
OR (a.usable_type = 'MARKETING_DELIVERY' AND NOT EXISTS (SELECT 1 FROM marketing_delivery_products mdp WHERE mdp.id = a.usable_id))
OR (a.usable_type = 'RECORDING_STOCK' AND NOT EXISTS (
SELECT 1 FROM recording_stocks rs JOIN recordings r ON r.id = rs.recording_id
WHERE rs.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'RECORDING_DEPLETION' AND NOT EXISTS (
SELECT 1 FROM recording_depletions rd JOIN recordings r ON r.id = rd.recording_id
WHERE rd.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'STOCKTRANSFER_OUT' AND NOT EXISTS (
SELECT 1 FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id
WHERE std.id = a.usable_id AND std.deleted_at IS NULL AND st.deleted_at IS NULL
))
OR (a.usable_type = 'TRANSFERTOLAYING_OUT' AND NOT EXISTS (
SELECT 1 FROM laying_transfers lt WHERE lt.id = a.usable_id AND lt.deleted_at IS NULL
))
)
SELECT usable_type, COUNT(*) AS rows, COALESCE(SUM(qty),0) AS total_qty
FROM orphan
GROUP BY usable_type
ORDER BY usable_type;
-- Detail rows (limit)
WITH active_alloc AS (
SELECT id, usable_type, usable_id, stockable_type, stockable_id, product_warehouse_id, qty
FROM stock_allocations
WHERE status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
AND deleted_at IS NULL
),
orphan AS (
SELECT a.*
FROM active_alloc a
WHERE
(a.usable_type = 'ADJUSTMENT_OUT' AND NOT EXISTS (SELECT 1 FROM adjustment_stocks ad WHERE ad.id = a.usable_id))
OR (a.usable_type = 'MARKETING_DELIVERY' AND NOT EXISTS (SELECT 1 FROM marketing_delivery_products mdp WHERE mdp.id = a.usable_id))
OR (a.usable_type = 'RECORDING_STOCK' AND NOT EXISTS (
SELECT 1 FROM recording_stocks rs JOIN recordings r ON r.id = rs.recording_id
WHERE rs.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'RECORDING_DEPLETION' AND NOT EXISTS (
SELECT 1 FROM recording_depletions rd JOIN recordings r ON r.id = rd.recording_id
WHERE rd.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'STOCKTRANSFER_OUT' AND NOT EXISTS (
SELECT 1 FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id
WHERE std.id = a.usable_id AND std.deleted_at IS NULL AND st.deleted_at IS NULL
))
OR (a.usable_type = 'TRANSFERTOLAYING_OUT' AND NOT EXISTS (
SELECT 1 FROM laying_transfers lt WHERE lt.id = a.usable_id AND lt.deleted_at IS NULL
))
)
SELECT *
FROM orphan
ORDER BY usable_type, usable_id, id
LIMIT 200;
@@ -0,0 +1,52 @@
-- Cleanup orphan stock_allocations (ACTIVE + CONSUME) by releasing them.
-- IMPORTANT: run audit first.
-- Usage:
-- psql -U app_lti_user -d db_lti_erp -f scripts/sql/orphan_allocations_cleanup.sql
BEGIN;
WITH active_alloc AS (
SELECT id, usable_type, usable_id
FROM stock_allocations
WHERE status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
AND deleted_at IS NULL
),
orphan AS (
SELECT a.id
FROM active_alloc a
WHERE
(a.usable_type = 'ADJUSTMENT_OUT' AND NOT EXISTS (SELECT 1 FROM adjustment_stocks ad WHERE ad.id = a.usable_id))
OR (a.usable_type = 'MARKETING_DELIVERY' AND NOT EXISTS (SELECT 1 FROM marketing_delivery_products mdp WHERE mdp.id = a.usable_id))
OR (a.usable_type = 'RECORDING_STOCK' AND NOT EXISTS (
SELECT 1 FROM recording_stocks rs JOIN recordings r ON r.id = rs.recording_id
WHERE rs.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'RECORDING_DEPLETION' AND NOT EXISTS (
SELECT 1 FROM recording_depletions rd JOIN recordings r ON r.id = rd.recording_id
WHERE rd.id = a.usable_id AND r.deleted_at IS NULL
))
OR (a.usable_type = 'STOCKTRANSFER_OUT' AND NOT EXISTS (
SELECT 1 FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id
WHERE std.id = a.usable_id AND std.deleted_at IS NULL AND st.deleted_at IS NULL
))
OR (a.usable_type = 'TRANSFERTOLAYING_OUT' AND NOT EXISTS (
SELECT 1 FROM laying_transfers lt WHERE lt.id = a.usable_id AND lt.deleted_at IS NULL
))
),
updated AS (
UPDATE stock_allocations sa
SET
status = 'RELEASED',
released_at = NOW(),
note = CONCAT(COALESCE(sa.note, ''), CASE WHEN COALESCE(sa.note, '') = '' THEN '' ELSE ' | ' END, 'orphan_cleanup')
WHERE sa.id IN (SELECT id FROM orphan)
RETURNING sa.id, sa.usable_type, sa.usable_id, sa.qty
)
SELECT usable_type, COUNT(*) AS rows, COALESCE(SUM(qty),0) AS total_qty
FROM updated
GROUP BY usable_type
ORDER BY usable_type;
COMMIT;