Merge branch 'development' into feat/kandang-groups

This commit is contained in:
giovanni
2026-03-09 11:17:10 +07:00
34 changed files with 2421 additions and 557 deletions
+9
View File
@@ -59,6 +59,9 @@ build_mr:
- *ecr_login
script: |
set -eu
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
echo "Build (MR) : $ECR_REPOSITORY:$IMAGE_TAG"
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
echo "Pushing image for MR..."
@@ -82,6 +85,9 @@ build_push_dev:
- *ecr_login
script: |
set -eu
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
echo "Build & push (dev): $ECR_REPOSITORY:$IMAGE_TAG"
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
@@ -138,6 +144,9 @@ build_push_prod:
- *ecr_login
script: |
set -eu
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
echo "Build & push (prod): $ECR_REPOSITORY:$IMAGE_TAG"
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
+2 -2
View File
@@ -1,7 +1,7 @@
# =========================
# Builder stage
# =========================
FROM golang:1.23-alpine AS builder
FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder
RUN apk add --no-cache git ca-certificates tzdata
WORKDIR /app
@@ -25,7 +25,7 @@ RUN GOBIN=/usr/local/bin go install -tags "postgres file" -ldflags="-s -w" githu
# =========================
# Runtime stage
# =========================
FROM alpine:3.20
FROM public.ecr.aws/docker/library/alpine:3.20
RUN apk add --no-cache ca-certificates tzdata curl bash postgresql-client \
&& adduser -D -H -u 10001 appuser
@@ -141,6 +141,9 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
if remaining <= 0 {
break
}
if shouldSkipStockableForUsable(req, lot.Ref.LegacyTypeKey) {
continue
}
if lot.AvailableQuantity <= 0 {
continue
}
@@ -207,6 +210,20 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
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
}
return false
}
func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) {
if err := s.validateRollbackRequest(req); err != nil {
return nil, err
@@ -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,5 +1,60 @@
BEGIN;
-- no-op: moved to 20260306090010_seed_fifo_stock_v2_config_after_core.down.sql
DO $$
BEGIN
IF to_regclass('public.fifo_stock_v2_overconsume_rules') IS NOT NULL THEN
EXECUTE '
DELETE FROM fifo_stock_v2_overconsume_rules
WHERE reason IN (
''fifo_v2_default_allow'',
''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;
IF to_regclass('public.fifo_stock_v2_route_rules') IS NOT NULL THEN
EXECUTE '
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
IF to_regclass('public.fifo_stock_v2_traits') IS NOT NULL THEN
EXECUTE '
DELETE FROM fifo_stock_v2_traits
WHERE source_table IN (
''purchase_items'',
''stock_transfer_details'',
''laying_transfer_targets'',
''laying_transfer_sources'',
''adjustment_stocks'',
''recording_stocks'',
''recording_depletions'',
''recording_eggs'',
''marketing_delivery_products'',
''project_chickins'',
''project_flock_populations''
)
';
END IF;
IF to_regclass('public.fifo_stock_v2_flag_members') IS NOT NULL THEN
EXECUTE '
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_group_code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
IF to_regclass('public.fifo_stock_v2_flag_groups') IS NOT NULL THEN
EXECUTE '
DELETE FROM fifo_stock_v2_flag_groups
WHERE code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
END $$;
COMMIT;
@@ -0,0 +1,8 @@
BEGIN;
DROP INDEX IF EXISTS idx_laying_transfers_economic_cutoff_date;
ALTER TABLE laying_transfers
DROP COLUMN IF EXISTS economic_cutoff_date;
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
ALTER TABLE laying_transfers
ADD COLUMN IF NOT EXISTS economic_cutoff_date DATE;
CREATE INDEX IF NOT EXISTS idx_laying_transfers_economic_cutoff_date
ON laying_transfers(economic_cutoff_date);
UPDATE laying_transfers
SET economic_cutoff_date = COALESCE(economic_cutoff_date, effective_move_date, transfer_date)
WHERE economic_cutoff_date IS NULL;
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;
+28 -20
View File
@@ -7,25 +7,33 @@ import (
)
type LayingTransfer struct {
Id uint `gorm:"primaryKey"`
TransferNumber string `gorm:"uniqueIndex;not null"`
FromProjectFlockId uint `gorm:"not null"`
ToProjectFlockId uint `gorm:"not null"`
TransferDate time.Time `gorm:"type:date;not null"`
EffectiveMoveDate *time.Time `gorm:"type:date"`
ExecutedAt *time.Time `gorm:"type:timestamptz"`
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"`
Id uint `gorm:"primaryKey"`
TransferNumber string `gorm:"uniqueIndex;not null"`
FromProjectFlockId uint `gorm:"not null"`
ToProjectFlockId uint `gorm:"not null"`
SourceProjectFlockKandangId *uint `gorm:"index"`
SourceProductWarehouseId *uint `gorm:"index"`
SourceRequestedQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
SourceUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
SourcePendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
TransferDate time.Time `gorm:"type:date;not null"`
EconomicCutoffDate *time.Time `gorm:"type:date"`
EffectiveMoveDate *time.Time `gorm:"type:date"`
ExecutedAt *time.Time `gorm:"type:timestamptz"`
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"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"`
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"`
SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"`
}
+2
View File
@@ -43,4 +43,6 @@ type Recording struct {
StandardEggMass *float64 `gorm:"-"`
StandardEggWeight *float64 `gorm:"-"`
StandardFcr *float64 `gorm:"-"`
PopulationCanChange *bool `gorm:"-"`
TransferExecuted *bool `gorm:"-"`
}
@@ -1364,8 +1364,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
COALESCE(p.product_price, 0) AS price
`).
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 = lts.product_warehouse_id").
Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lt.source_product_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 warehouses w ON w.id = pw.warehouse_id").
@@ -1427,8 +1426,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
COALESCE(SUM(sa.qty), 0) AS qty_out,
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 = lts.laying_transfer_id").
Joins("JOIN laying_transfers lt ON lt.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
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 warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
@@ -1440,7 +1438,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
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 = applyDateRange(outgoingLayingQuery, "lt.transfer_date", start, end)
outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery)
@@ -26,6 +26,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
userRepo := rUser.NewUserRepository(db)
productRepo := rproduct.NewProductRepository(db)
adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db)
@@ -40,6 +41,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat
fifoStockV2Service,
validate,
projectFlockKandangRepo,
projectFlockPopulationRepo,
)
userService := sUser.NewUserService(userRepo, validate)
@@ -11,6 +11,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
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"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
@@ -21,6 +22,7 @@ import (
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/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/fifo"
"gorm.io/gorm"
)
@@ -31,15 +33,16 @@ type AdjustmentService interface {
}
type adjustmentService struct {
Log *logrus.Logger
Validate *validator.Validate
StockLogsRepository stockLogsRepo.StockLogRepository
WarehouseRepo warehouseRepo.WarehouseRepository
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
ProductRepo productRepo.ProductRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository
FifoStockV2Svc common.FifoStockV2Service
Log *logrus.Logger
Validate *validator.Validate
StockLogsRepository stockLogsRepo.StockLogRepository
WarehouseRepo warehouseRepo.WarehouseRepository
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
ProductRepo productRepo.ProductRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository
AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository
FifoStockV2Svc common.FifoStockV2Service
}
const (
@@ -57,17 +60,19 @@ func NewAdjustmentService(
fifoStockV2Svc common.FifoStockV2Service,
validate *validator.Validate,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository,
) AdjustmentService {
return &adjustmentService{
Log: utils.Log,
Validate: validate,
StockLogsRepository: stockLogsRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProductRepo: productRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
AdjustmentStockRepository: adjustmentStockRepo,
FifoStockV2Svc: fifoStockV2Svc,
Log: utils.Log,
Validate: validate,
StockLogsRepository: stockLogsRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProductRepo: productRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
AdjustmentStockRepository: adjustmentStockRepo,
FifoStockV2Svc: fifoStockV2Svc,
}
}
@@ -309,6 +314,22 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err != nil {
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 := s.resyncProjectFlockPopulationUsage(ctx, tx, *projectFlockKandangID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to resync project flock population usage")
}
}
if err := s.createAdjustmentStockLog(
ctx,
@@ -614,6 +635,98 @@ func (s *adjustmentService) createAdjustmentStockLog(
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) resyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if tx == nil || 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 := tx.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 (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) {
if err := s.Validate.Struct(query); err != nil {
return nil, 0, err
@@ -38,6 +38,14 @@ func (u *ProductController) GetAll(c *fiber.Ctx) error {
query.IsDepletion = &value
}
if includeAllParam := c.Query("include_all", ""); includeAllParam != "" {
value, err := strconv.ParseBool(includeAllParam)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid include_all value")
}
query.IncludeAll = &value
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
@@ -229,9 +229,9 @@ 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 {
db = s.withRelations(db)
// 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 {
// Default: show only visible products.
// include_all=true can be used to fetch all records (including hidden/system products).
if params.IncludeAll == nil || !*params.IncludeAll {
db = db.Where("is_visible = ?", true)
}
if params.Search != "" {
@@ -45,4 +45,5 @@ type Query struct {
Search string `query:"search" validate:"omitempty,max=50"`
ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"`
IsDepletion *bool `query:"is_depletion" validate:"omitempty"`
IncludeAll *bool `query:"include_all" validate:"omitempty"`
}
@@ -26,11 +26,15 @@ import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgconn"
pgconnv5 "github.com/jackc/pgx/v5/pgconn"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena sudah memiliki population. Lakukan rollback/penyesuaian population terlebih dahulu"
type ChickinService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error)
@@ -189,31 +193,31 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d belongs to different flock. Only product warehouses with project_flock_kandang_id = NULL or = %d can be used", chickinReq.ProductWarehouseId, req.ProjectFlockKandangId))
}
if productWarehouse.Product.Id != 0 {
category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category))
if category != string(utils.ProjectFlockCategoryGrowing) && category != string(utils.ProjectFlockCategoryLaying) {
return nil, fmt.Errorf("invalid flock category for chickin")
}
if productWarehouse.Product.Id != 0 {
category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category))
if category != string(utils.ProjectFlockCategoryGrowing) && category != string(utils.ProjectFlockCategoryLaying) {
return nil, fmt.Errorf("invalid flock category for chickin")
}
hasAyamFlag := false
for _, flag := range productWarehouse.Product.Flags {
if utils.CanonicalFlagType(flag.Name) == utils.FlagAyam {
hasAyamFlag = true
break
}
}
if !hasAyamFlag {
return nil, fmt.Errorf(
"product warehouse %d cannot be used for %s chickin. Product must have AYAM flag (or legacy alias DOC/PULLET/LAYER) (product ID: %d, warehouse ID: %d)",
chickinReq.ProductWarehouseId,
projectFlockKandang.ProjectFlock.Category,
productWarehouse.Product.Id,
productWarehouse.Id,
)
hasAyamFlag := false
for _, flag := range productWarehouse.Product.Flags {
if utils.CanonicalFlagType(flag.Name) == utils.FlagAyam {
hasAyamFlag = true
break
}
}
if !hasAyamFlag {
return nil, fmt.Errorf(
"product warehouse %d cannot be used for %s chickin. Product must have AYAM flag (or legacy alias DOC/PULLET/LAYER) (product ID: %d, warehouse ID: %d)",
chickinReq.ProductWarehouseId,
projectFlockKandang.ProjectFlock.Category,
productWarehouse.Product.Id,
productWarehouse.Id,
)
}
}
chickinDate, err := utils.ParseDateString(chickinReq.ChickInDate)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId))
@@ -421,6 +425,14 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil {
return err
}
hasPopulation, err := s.ProjectflockPopulationRepo.ExistsByProjectChickinID(c.Context(), chickin.Id)
if err != nil {
s.Log.Errorf("Failed to check population by chickin %d: %+v", chickin.Id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi population chickin")
}
if hasPopulation {
return fiber.NewError(fiber.StatusBadRequest, chickinDeletePopulationGuardMessage)
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
@@ -429,17 +441,35 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
chickinRepoTx := repository.NewChickinRepository(tx)
if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 {
if err := s.ReleaseChickinStocks(c.Context(), tx, chickin, actorID); err != nil {
return err
}
}
now := time.Now().UTC()
note := "delete chickin rollback"
if err := tx.WithContext(c.Context()).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?",
fifo.UsableKeyProjectChickin.String(),
chickin.Id,
entity.StockAllocationStatusActive,
).
Updates(map[string]any{
"status": entity.StockAllocationStatusReleased,
"released_at": now,
"note": note,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal release alokasi FIFO chickin")
}
if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Chickin not found")
}
if isForeignKeyViolation(err) {
return fiber.NewError(fiber.StatusBadRequest, chickinDeletePopulationGuardMessage)
}
return err
}
@@ -459,6 +489,24 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
return nil
}
func isForeignKeyViolation(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23503"
}
var pgErrV5 *pgconnv5.PgError
if errors.As(err, &pgErrV5) {
return pgErrV5.Code == "23503"
}
return false
}
func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
@@ -69,22 +69,24 @@ type RecordingWarehouseDTO struct {
}
type RecordingRelationDTO struct {
Id uint `json:"id"`
ProjectFlock RecordingProjectFlockDTO `json:"project_flock"`
RecordDatetime time.Time `json:"record_datetime"`
Day int `json:"day"`
TotalDepletionQty float64 `json:"total_depletion_qty"`
TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"`
CumDepletionRate float64 `json:"cum_depletion_rate"`
DepletionRate float64 `json:"depletion_rate"`
CumIntake int `json:"cum_intake"`
FcrValue float64 `json:"fcr_value"`
HenDay float64 `json:"hen_day"`
HenHouse float64 `json:"hen_house"`
FeedIntake float64 `json:"feed_intake"`
EggMass float64 `json:"egg_mass"`
EggWeight float64 `json:"egg_weight"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
Id uint `json:"id"`
ProjectFlock RecordingProjectFlockDTO `json:"project_flock"`
RecordDatetime time.Time `json:"record_datetime"`
Day int `json:"day"`
TotalDepletionQty float64 `json:"total_depletion_qty"`
TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"`
CumDepletionRate float64 `json:"cum_depletion_rate"`
DepletionRate float64 `json:"depletion_rate"`
CumIntake int `json:"cum_intake"`
FcrValue float64 `json:"fcr_value"`
HenDay float64 `json:"hen_day"`
HenHouse float64 `json:"hen_house"`
FeedIntake float64 `json:"feed_intake"`
EggMass float64 `json:"egg_mass"`
EggWeight float64 `json:"egg_weight"`
PopulationCanChange bool `json:"population_can_change"`
TransferExecuted *bool `json:"transfer_executed,omitempty"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type RecordingListDTO struct {
@@ -228,22 +230,24 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
}
return RecordingRelationDTO{
Id: e.Id,
ProjectFlock: toRecordingProjectFlockDTO(e),
RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day),
TotalDepletionQty: floatValue(e.TotalDepletionQty),
Id: e.Id,
ProjectFlock: toRecordingProjectFlockDTO(e),
RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day),
TotalDepletionQty: floatValue(e.TotalDepletionQty),
TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty),
CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2),
DepletionRate: roundFloatValue(e.DepletionRate, 2),
CumIntake: intValue(e.CumIntake),
FcrValue: floatValue(e.FcrValue),
HenDay: floatValue(e.HenDay),
HenHouse: floatValue(e.HenHouse),
FeedIntake: floatValue(e.FeedIntake),
EggMass: floatValue(e.EggMass),
EggWeight: floatValue(e.EggWeight),
Approval: latestApproval,
CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2),
DepletionRate: roundFloatValue(e.DepletionRate, 2),
CumIntake: intValue(e.CumIntake),
FcrValue: floatValue(e.FcrValue),
HenDay: floatValue(e.HenDay),
HenHouse: floatValue(e.HenHouse),
FeedIntake: floatValue(e.FeedIntake),
EggMass: floatValue(e.EggMass),
EggWeight: floatValue(e.EggWeight),
PopulationCanChange: boolValueDefault(e.PopulationCanChange, true),
TransferExecuted: e.TransferExecuted,
Approval: latestApproval,
}
}
@@ -449,6 +453,13 @@ func intValue(value *int) int {
return *value
}
func boolValueDefault(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO {
result := approvalDTO.ApprovalRelationDTO{}
@@ -2,6 +2,7 @@ package recordings
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -24,8 +25,10 @@ import (
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
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"
sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
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/fifo"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
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)
chickinDetailRepo := rChickin.NewChickinDetailRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
layingTransferSourceRepo := rTransferLaying.NewLayingTransferSourceRepository(db)
layingTransferTargetRepo := rTransferLaying.NewLayingTransferTargetRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(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)
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)
approvalService := commonSvc.NewApprovalService(approvalRepo)
@@ -103,6 +144,21 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
fifoStockV2Service,
)
transferLayingService := sTransferLaying.NewTransferLayingService(
transferLayingRepo,
layingTransferSourceRepo,
layingTransferTargetRepo,
projectFlockRepo,
projectFlockKandangRepo,
projectFlockPopulationRepo,
productWarehouseRepo,
warehouseRepo,
approvalService,
fifoService,
fifoStockV2Service,
validate,
)
recordingService := sRecording.NewRecordingService(
recordingRepo,
projectFlockKandangRepo,
@@ -116,6 +172,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockService,
chickinService,
transferLayingRepo,
transferLayingService,
validate,
)
userService := sUser.NewUserService(userRepo, validate)
@@ -21,6 +21,7 @@ import (
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
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"
sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
@@ -56,6 +57,7 @@ type recordingService struct {
ProjectFlockSvc sProjectFlock.ProjectflockService
ChickinSvc sChickin.ChickinService
TransferLayingRepo rTransferLaying.TransferLayingRepository
TransferLayingSvc sTransferLaying.TransferLayingService
FifoStockV2Svc commonSvc.FifoStockV2Service
StockLogRepo rStockLogs.StockLogRepository
}
@@ -73,6 +75,7 @@ func NewRecordingService(
projectFlockSvc sProjectFlock.ProjectflockService,
chickinSvc sChickin.ChickinService,
transferLayingRepo rTransferLaying.TransferLayingRepository,
transferLayingSvc sTransferLaying.TransferLayingService,
validate *validator.Validate,
) RecordingService {
return &recordingService{
@@ -88,6 +91,7 @@ func NewRecordingService(
ProjectFlockSvc: projectFlockSvc,
ChickinSvc: chickinSvc,
TransferLayingRepo: transferLayingRepo,
TransferLayingSvc: transferLayingSvc,
FifoStockV2Svc: fifoStockV2Svc,
StockLogRepo: stockLogRepo,
}
@@ -180,6 +184,13 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
totalChick := totalChickMap[recordings[i].ProjectFlockKandangId]
rate := recordingutil.ComputeDepletionRate(prev, current, totalChick)
recordings[i].DepletionRate = &rate
populationCanChange, transferExecuted, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i])
if stateErr != nil {
return nil, 0, stateErr
}
recordings[i].PopulationCanChange = boolPtr(populationCanChange)
recordings[i].TransferExecuted = boolPtr(transferExecuted)
}
return recordings, total, nil
}
@@ -239,6 +250,14 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro
rate := recordingutil.ComputeDepletionRate(prev, current, totalChick)
recording.DepletionRate = &rate
}
populationCanChange, transferExecuted, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), recording)
if stateErr != nil {
return nil, stateErr
}
recording.PopulationCanChange = boolPtr(populationCanChange)
recording.TransferExecuted = boolPtr(transferExecuted)
return recording, nil
}
@@ -293,7 +312,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
category := strings.ToUpper(pfk.ProjectFlock.Category)
isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying))
if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime); err != nil {
if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfk, recordTime); err != nil {
return nil, err
}
routePayload := buildRecordingRoutePayloadFromCreate(req)
if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime, routePayload); err != nil {
return nil, err
}
@@ -418,8 +442,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if err := s.reflowApplyRecordingDepletionsIn(ctx, tx, mappedDepletions); err != nil {
return err
}
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, createdRecording.ProjectFlockKandangId); err != nil {
s.Log.Errorf("Failed to resync project flock population usage: %+v", err)
if err := s.resyncPopulationUsageForDepletions(ctx, tx, createdRecording.ProjectFlockKandangId, mappedDepletions); err != nil {
s.Log.Errorf("Failed to resync depletion source population usage: %+v", err)
return err
}
@@ -494,6 +518,26 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
recordingEntity = recording
if err := s.ensurePopulationMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
return err
}
pfkForRoute := recordingEntity.ProjectFlockKandang
if pfkForRoute == nil || pfkForRoute.Id == 0 {
fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId)
if fetchErr != nil {
if errors.Is(fetchErr, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found")
}
s.Log.Errorf("Failed to fetch project flock kandang for route validation: %+v", fetchErr)
return fetchErr
}
pfkForRoute = fetchedPfk
}
routePayload := buildRecordingRoutePayloadFromUpdate(req, recordingEntity)
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
return err
}
hasStockChanges := req.Stocks != nil
hasDepletionChanges := req.Depletions != nil
hasEggChanges := req.Eggs != nil
@@ -501,6 +545,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
var existingStocks []entity.RecordingStock
var existingDepletions []entity.RecordingDepletion
var existingEggs []entity.RecordingEgg
var mappedDepletions []entity.RecordingDepletion
note := recordingutil.RecordingNote("Edit", recordingEntity.Id)
@@ -545,6 +590,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if match {
hasDepletionChanges = false
} else {
if err := s.ensureDepletionMutationAllowed(ctx, recordingEntity, "ubah"); err != nil {
return err
}
if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, nil); err != nil {
return err
}
@@ -564,7 +612,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err
}
mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions)
mappedDepletions = recordingutil.MapDepletions(recordingEntity.Id, req.Depletions)
if len(mappedDepletions) > 0 {
if err := s.ensureDepletionWithinPopulation(ctx, tx, recordingEntity.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), sumDepletionQty(existingDepletions)); err != nil {
return err
@@ -655,8 +703,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return nil
}
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recordingEntity.ProjectFlockKandangId); err != nil {
s.Log.Errorf("Failed to resync project flock population usage: %+v", err)
if err := s.resyncPopulationUsageForDepletions(ctx, tx, recordingEntity.ProjectFlockKandangId, append(existingDepletions, mappedDepletions...)); err != nil {
s.Log.Errorf("Failed to resync depletion source population usage: %+v", err)
return err
}
@@ -808,6 +856,11 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent
if action == entity.ApprovalActionRejected {
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 {
return err
}
@@ -821,8 +874,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)
return err
}
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recording.ProjectFlockKandangId); err != nil {
s.Log.Errorf("Failed to resync project flock population usage after reject %d: %+v", id, err)
if err := s.resyncPopulationUsageForDepletions(ctx, tx, recording.ProjectFlockKandangId, existingDepletions); err != nil {
s.Log.Errorf("Failed to resync depletion source population usage after reject %d: %+v", id, err)
return err
}
if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
@@ -878,6 +931,19 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
s.Log.Errorf("Failed to find recording: %+v", err)
return err
}
if err := s.ensurePopulationMutationAllowed(ctx, recording, "hapus"); err != nil {
return err
}
existingDepletions, err := s.Repository.ListDepletions(tx, recording.Id)
if err != nil {
s.Log.Errorf("Failed to list existing depletions: %+v", err)
return err
}
if len(existingDepletions) > 0 {
if err := s.ensureDepletionMutationAllowed(ctx, recording, "hapus"); err != nil {
return err
}
}
if err := s.reflowRollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil {
return err
@@ -891,8 +957,8 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err
}
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recording.ProjectFlockKandangId); err != nil {
s.Log.Errorf("Failed to resync project flock population usage: %+v", err)
if err := s.resyncPopulationUsageForDepletions(ctx, tx, recording.ProjectFlockKandangId, existingDepletions); err != nil {
s.Log.Errorf("Failed to resync depletion source population usage after delete: %+v", err)
return err
}
@@ -905,10 +971,201 @@ 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, *entity.LayingTransfer, time.Time, error) {
if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil {
return true, 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, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
if category != strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) {
return true, false, nil, time.Time{}, nil
}
transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, false, nil, time.Time{}, nil
}
s.Log.Errorf("Failed to resolve approved transfer by source kandang for recording %d: %+v", recording.Id, err)
return true, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
if transfer == nil {
return true, false, nil, time.Time{}, nil
}
transferDate := transferPhysicalMoveDate(transfer)
if transferDate.IsZero() {
return true, false, transfer, transferDate, nil
}
transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero()
recordDate := normalizeDateOnlyUTC(recording.RecordDatetime)
populationCanChange := !(transferExecuted && !recordDate.Before(transferDate))
return populationCanChange, transferExecuted, transfer, transferDate, 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.Id == 0 || 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))
}
var (
transfer *entity.LayingTransfer
err error
)
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 nil
}
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)
physicalMoveDate := transferPhysicalMoveDate(transfer)
if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) {
return nil
}
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Deplesi recording tanggal %s tidak dapat di%s karena sudah mempengaruhi transfer laying %s yang sudah dieksekusi. Lakukan unexecute transfer terlebih dahulu bila belum ada pemakaian downstream.",
recordDate.Format("2006-01-02"),
operation,
transfer.TransferNumber,
),
)
}
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(
ctx context.Context,
pfk *entity.ProjectFlockKandang,
recordTime time.Time,
payload recordingRoutePayload,
) error {
if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil {
return nil
@@ -928,22 +1185,35 @@ func (s *recordingService) enforceTransferRecordingRoute(
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
effectiveDate := effectiveTransferDate(transfer)
if effectiveDate.IsZero() {
physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer)
if physicalMoveDate.IsZero() {
return nil
}
if recordDate.Before(effectiveDate) {
if recordDate.Before(physicalMoveDate) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s. Sebelumnya gunakan kandang growing", effectiveDate.Format("2006-01-02")),
fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s (tanggal pindah fisik). Sebelumnya gunakan kandang growing", physicalMoveDate.Format("2006-01-02")),
)
}
if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Transfer laying %s sudah efektif pada %s tetapi belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, effectiveDate.Format("2006-01-02")),
fmt.Sprintf("Transfer laying %s dengan tanggal pindah fisik %s belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")),
)
}
if recordDate.Before(economicCutoffDate) && payload.StockCount > 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Periode transisi transfer laying %s (%s s.d. %s): input PAKAN/OVK harus dicatat di kandang growing hingga %s. Recording kandang laying pada periode ini hanya untuk deplesi (dan telur bila ada).",
transfer.TransferNumber,
physicalMoveDate.Format("2006-01-02"),
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
),
)
}
@@ -957,22 +1227,38 @@ func (s *recordingService) enforceTransferRecordingRoute(
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
if transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() {
return fiber.NewError(
fiber.StatusBadRequest,
"Project flock kandang sudah dipindahkan ke laying",
)
}
effectiveDate := effectiveTransferDate(transfer)
if effectiveDate.IsZero() {
physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer)
if physicalMoveDate.IsZero() {
return nil
}
if !recordDate.Before(effectiveDate) {
if recordDate.Before(physicalMoveDate) {
return nil
}
if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", effectiveDate.AddDate(0, 0, -1).Format("2006-01-02"), effectiveDate.Format("2006-01-02")),
fmt.Sprintf("Transfer laying %s sudah memasuki tanggal pindah fisik %s namun belum dieksekusi. Eksekusi transfer lalu lakukan recording transisi sesuai aturan", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")),
)
}
if !recordDate.Before(economicCutoffDate) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), economicCutoffDate.Format("2006-01-02")),
)
}
if payload.DepletionCount > 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Periode transisi transfer laying %s (%s s.d. %s): deplesi harus dicatat di kandang laying tujuan agar mapping tidak ambigu. Kandang growing pada periode ini hanya untuk PAKAN/OVK.",
transfer.TransferNumber,
physicalMoveDate.Format("2006-01-02"),
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
),
)
}
}
@@ -980,23 +1266,138 @@ func (s *recordingService) enforceTransferRecordingRoute(
return nil
}
func effectiveTransferDate(transfer *entity.LayingTransfer) time.Time {
type recordingRoutePayload struct {
StockCount int
DepletionCount int
EggCount int
}
func buildRecordingRoutePayloadFromCreate(req *validation.Create) recordingRoutePayload {
payload := recordingRoutePayload{}
if req == nil {
return payload
}
for _, stock := range req.Stocks {
if stock.Qty > 0 {
payload.StockCount++
}
}
for _, depletion := range req.Depletions {
if depletion.Qty > 0 {
payload.DepletionCount++
}
}
for _, egg := range req.Eggs {
if egg.Qty > 0 {
payload.EggCount++
}
}
return payload
}
func buildRecordingRoutePayloadFromUpdate(req *validation.Update, existing *entity.Recording) recordingRoutePayload {
payload := recordingRoutePayload{}
if req == nil && existing == nil {
return payload
}
if req != nil && req.Stocks != nil {
for _, stock := range req.Stocks {
if stock.Qty > 0 {
payload.StockCount++
}
}
} else if existing != nil {
for _, stock := range existing.Stocks {
usageQty := 0.0
if stock.UsageQty != nil {
usageQty = *stock.UsageQty
}
pendingQty := 0.0
if stock.PendingQty != nil {
pendingQty = *stock.PendingQty
}
if usageQty > 0 || pendingQty > 0 {
payload.StockCount++
}
}
}
if req != nil && req.Depletions != nil {
for _, depletion := range req.Depletions {
if depletion.Qty > 0 {
payload.DepletionCount++
}
}
} else if existing != nil {
for _, depletion := range existing.Depletions {
if depletion.Qty > 0 {
payload.DepletionCount++
}
}
}
if req != nil && req.Eggs != nil {
for _, egg := range req.Eggs {
if egg.Qty > 0 {
payload.EggCount++
}
}
} else if existing != nil {
for _, egg := range existing.Eggs {
if egg.Qty > 0 {
payload.EggCount++
}
}
}
return payload
}
func transferPhysicalMoveDate(transfer *entity.LayingTransfer) time.Time {
if transfer == nil {
return time.Time{}
}
if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() {
return normalizeDateOnlyUTC(*transfer.EffectiveMoveDate)
}
if !transfer.TransferDate.IsZero() {
return normalizeDateOnlyUTC(transfer.TransferDate)
}
return time.Time{}
}
func transferEconomicCutoffDate(transfer *entity.LayingTransfer) time.Time {
if transfer == nil {
return time.Time{}
}
if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() {
return normalizeDateOnlyUTC(*transfer.EconomicCutoffDate)
}
if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() {
return normalizeDateOnlyUTC(*transfer.EffectiveMoveDate)
}
return transferPhysicalMoveDate(transfer)
}
func transferRecordingWindow(transfer *entity.LayingTransfer) (time.Time, time.Time) {
physicalMoveDate := transferPhysicalMoveDate(transfer)
economicCutoffDate := transferEconomicCutoffDate(transfer)
if economicCutoffDate.IsZero() {
economicCutoffDate = physicalMoveDate
}
if !physicalMoveDate.IsZero() && economicCutoffDate.Before(physicalMoveDate) {
economicCutoffDate = physicalMoveDate
}
return physicalMoveDate, economicCutoffDate
}
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 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 {
idSet := make(map[uint]struct{})
@@ -2184,6 +2585,119 @@ func sumDepletionQty(items []entity.RecordingDepletion) float64 {
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 {
db := s.Repository.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var sourceKandangIDs []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 ?", sourceWarehouseIDs).
Where("pfp.deleted_at IS NULL").
Where("pc.deleted_at IS NULL").
Pluck("pc.project_flock_kandang_id", &sourceKandangIDs).Error; err != nil {
return err
}
for _, kandangID := range sourceKandangIDs {
if kandangID != 0 {
kandangIDs[kandangID] = struct{}{}
}
}
}
for kandangID := range kandangIDs {
if err := s.resyncPopulationUsageByProjectFlockKandang(ctx, tx, kandangID); err != nil {
return err
}
}
return nil
}
func (s *recordingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
db := s.Repository.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var populationIDs []uint
if err := db.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 := db.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 := db.Model(&entity.ProjectFlockPopulation{}).
Where("id IN ?", populationIDs).
Update("total_used_qty", 0).Error; err != nil {
return err
}
for _, row := range usageRows {
if err := db.Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", row.StockableID).
Update("total_used_qty", row.Used).Error; err != nil {
return err
}
}
return nil
}
func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error {
if projectFlockKandangId == 0 || newTotal <= 0 {
return nil
@@ -0,0 +1,87 @@
package service
import (
"testing"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func mustDate(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse("2006-01-02", value)
if err != nil {
t.Fatalf("failed parsing date %s: %v", value, err)
}
return parsed
}
func TestTransferRecordingWindow(t *testing.T) {
t.Run("early transfer keeps transition until economic cutoff", func(t *testing.T) {
physical := mustDate(t, "2026-04-08")
cutoff := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EconomicCutoffDate: &cutoff,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-04-08" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
t.Run("standard transfer has no transition window", func(t *testing.T) {
physical := mustDate(t, "2026-05-13")
cutoff := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EconomicCutoffDate: &cutoff,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
t.Run("late transfer clamps economic cutoff to physical move", func(t *testing.T) {
physical := mustDate(t, "2026-06-03")
cutoff := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EconomicCutoffDate: &cutoff,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-06-03" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-06-03" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
t.Run("legacy data falls back to effective move date", func(t *testing.T) {
physical := mustDate(t, "2026-04-08")
legacyEffective := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EffectiveMoveDate: &legacyEffective,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-04-08" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
}
@@ -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 {
projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32)
if err != nil {
@@ -14,12 +14,13 @@ import (
// === DTO Structs ===
type TransferLayingRelationDTO struct {
Id uint `json:"id"`
TransferNumber string `json:"transfer_number"`
TransferDate time.Time `json:"transfer_date"`
EffectiveMoveDate *time.Time `json:"effective_move_date,omitempty"`
ExecutedAt *time.Time `json:"executed_at,omitempty"`
Notes string `json:"notes"`
Id uint `json:"id"`
TransferNumber string `json:"transfer_number"`
TransferDate time.Time `json:"transfer_date"`
EconomicCutoffDate *time.Time `json:"economic_cutoff_date,omitempty"`
EffectiveMoveDate *time.Time `json:"effective_move_date,omitempty"`
ExecutedAt *time.Time `json:"executed_at,omitempty"`
Notes string `json:"notes"`
}
type ProjectFlockKandangWithKandangDTO struct {
@@ -92,12 +93,13 @@ type MaxTargetQtyForTransferDTO struct {
func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO {
return TransferLayingRelationDTO{
Id: e.Id,
TransferNumber: e.TransferNumber,
TransferDate: e.TransferDate,
EffectiveMoveDate: e.EffectiveMoveDate,
ExecutedAt: e.ExecutedAt,
Notes: e.Notes,
Id: e.Id,
TransferNumber: e.TransferNumber,
TransferDate: e.TransferDate,
EconomicCutoffDate: e.EconomicCutoffDate,
EffectiveMoveDate: e.EffectiveMoveDate,
ExecutedAt: e.ExecutedAt,
Notes: e.Notes,
}
}
@@ -150,6 +152,46 @@ func ToLayingTransferSourceDTOs(sources []entity.LayingTransferSource) []LayingT
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 {
var pfkDTO *ProjectFlockKandangWithKandangDTO
if target.TargetProjectFlockKandang != nil && target.TargetProjectFlockKandang.Id != 0 {
@@ -254,7 +296,7 @@ func ToTransferLayingDetailDTO(e entity.LayingTransfer, approvals []entity.Appro
return TransferLayingDetailDTO{
TransferLayingListDTO: ToTransferLayingListDTO(e),
Sources: ToLayingTransferSourceDTOs(e.Sources),
Sources: toLayingTransferSourceDTOsFromTransfer(e),
Targets: ToLayingTransferTargetDTOs(e.Targets),
Approval: latestApproval,
}
@@ -276,7 +318,7 @@ func ToTransferLayingDetailDTOWithSingleApproval(e entity.LayingTransfer, approv
return TransferLayingDetailDTO{
TransferLayingListDTO: ToTransferLayingListDTO(e),
Sources: ToLayingTransferSourceDTOs(e.Sources),
Sources: toLayingTransferSourceDTOsFromTransfer(e),
Targets: ToLayingTransferTargetDTOs(e.Targets),
Approval: mappedApproval,
}
@@ -10,11 +10,11 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
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"
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"
"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"
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
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyTransferToLayingOut,
Table: "laying_transfer_sources",
Table: "laying_transfers",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_usage_qty",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "source_usage_qty",
PendingQuantity: "source_pending_usage_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
@@ -166,6 +166,9 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of
q = q.Offset(offset).Limit(limit).
Preload("FromProjectFlock").
Preload("ToProjectFlock").
Preload("SourceProjectFlockKandang").
Preload("SourceProjectFlockKandang.Kandang").
Preload("SourceProductWarehouse").
Preload("CreatedUser").
Preload("ExecutedUser").
Preload("Sources").
@@ -193,11 +196,12 @@ func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandang(ctx cont
var transfer entity.LayingTransfer
err := r.db.WithContext(ctx).
Model(&entity.LayingTransfer{}).
Joins("JOIN laying_transfer_sources lts ON lts.laying_transfer_id = laying_transfers.id AND lts.deleted_at IS NULL").
Where("lts.source_project_flock_kandang_id = ?", sourceProjectFlockKandangID).
Distinct("laying_transfers.*").
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(`(
SELECT a.action
SELECT a.action
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = laying_transfers.id
@@ -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.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/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/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang)
}
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"`
SourceProjectFlockId uint `json:"source_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"`
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"`
SourceProjectFlockId uint `json:"source_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"`
Reason string `json:"reason" validate:"omitempty,max=1000"`
}