Compare commits

...

101 Commits

Author SHA1 Message Date
Adnan Zahir 4b9986f4fc codex/command: migrate egg stocks from kandang to farm (adjustment_stocks only) 2026-04-08 13:49:08 +07:00
Adnan Zahir a7ab396bca codex/command: migrate egg stocks from kandang to farm 2026-04-08 13:49:08 +07:00
giovanni 50d239e26f adjust calculate total price create sales order for telur and convertion peti and qty 2026-04-08 13:49:08 +07:00
giovanni cf81d5086a remove migration change type data qty recording eggs 2026-04-08 13:49:08 +07:00
giovanni 0e8314f4cc add migration change data type field qty di table recording eggs 2026-04-08 13:49:08 +07:00
giovanni 5ea4ed4e66 add cmd for reflow quantity product warehouse from stock allocation 2026-04-08 13:49:08 +07:00
giovanni c2a8a5f08a adjust level 2 cmd adjust quantity product warehouse from purchase 2026-04-08 13:49:08 +07:00
giovanni 685f583f02 add command for adjust data quantity product warehouse from purchase items 2026-04-08 13:49:08 +07:00
giovanni 8079566ddf adjust response detail recording 2026-04-08 13:49:08 +07:00
giovanni 6f523b9709 adjust get data product suppliers 2026-04-08 13:49:08 +07:00
Adnan Zahir e6dc658046 codex/fix: hidden product warehouse depletion and egg <= 0 2026-04-08 13:49:08 +07:00
Adnan Zahir 491fe0abef codex/fix: store stocks on farm warehouse when recording egg 2026-04-08 13:49:08 +07:00
Adnan Zahir c07ba79ddb codex/fix: inconsistent stock options and availability 2026-04-08 13:49:08 +07:00
giovanni 480e430289 fix upser daily checklist status rejected; fix search list daily checklist 2026-04-08 13:49:08 +07:00
ragilap 07b55e79a5 fix filter purchase supplier repport 2026-04-08 13:49:01 +07:00
ragilap d54e8a4e02 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/filter-purchase 2026-04-01 23:20:15 +07:00
ragilap 8d9d06a757 fix filter purchase supplier repport 2026-04-01 16:25:04 +07:00
Adnan Zahir 9ce69ddeb0 Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: qty 0 after PO to farm-level warehouse

See merge request mbugroup/lti-api!385
2026-04-01 16:19:45 +07:00
Adnan Zahir 7b4bf94329 codex/fix: qty 0 after PO to farm-level warehouse 2026-04-01 16:14:07 +07:00
ragilap 796417d56f fix filter purchase ?approval_status=approved,rejected and ?product_category_id=1,2,3 2026-04-01 16:06:57 +07:00
ragilap 65409e5efa fix filter purchase query param and search 2026-04-01 15:58:00 +07:00
Adnan Zahir 524dc385ff Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: show farm stock usage on closing page

See merge request mbugroup/lti-api!384
2026-04-01 12:33:09 +07:00
Adnan Zahir 5ffb72507b codex/fix: show farm stock usage on closing page 2026-04-01 12:31:04 +07:00
Adnan Zahir 0bf9844efc Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: recheck and fix purchase receive failed and farm stock not shown on recording

See merge request mbugroup/lti-api!383
2026-04-01 11:48:45 +07:00
Adnan Zahir c4add1501d codex/fix: recheck and fix purchase receive failed and farm stock not shown on recording 2026-04-01 11:46:44 +07:00
Adnan Zahir f81e2f7c01 Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: purchase receivement error and recording doesn't show depletion/egg

See merge request mbugroup/lti-api!382
2026-04-01 11:10:19 +07:00
Adnan Zahir 030284a9b5 codex/fix: purchase receivement error and recording doesn't show depletion/egg 2026-04-01 11:03:35 +07:00
Adnan Zahir a55aa873a6 Merge branch 'codex/sales-at-farm-level' into 'development'
codex: initiated changes (farm-level warehouse stock manipulation on recording and sales)

See merge request mbugroup/lti-api!381
2026-04-01 10:18:45 +07:00
Adnan Zahir be00837148 codex: initiated changes 2026-03-30 13:40:29 +07:00
Adnan Zahir b6f369a5ec Merge branch 'fix/kandang-groups' into 'development'
[FIX][BE]: add restrict delete master data kandang group

See merge request mbugroup/lti-api!379
2026-03-27 14:39:49 +07:00
giovanni 63cf0c6fac add restrict delete master data kandang group 2026-03-27 12:11:17 +07:00
Adnan Zahir 6510bccc76 Merge branch 'fix/adjustment-population-chickin' into 'development'
fix asof chickin adjustment

See merge request mbugroup/lti-api!377
2026-03-25 12:43:16 +07:00
ragilap d226d5f7f3 fix asof chickin adjustment 2026-03-25 12:42:05 +07:00
Adnan Zahir 325825a709 Merge branch 'add/cut-over-logic-recording-response' into 'development'
add response logic cut over recording for view

See merge request mbugroup/lti-api!375
2026-03-17 23:55:48 +07:00
ragilap 3b3ee8b796 add response logic cut over recording for view 2026-03-17 23:50:58 +07:00
Adnan Zahir bf4aa5ccea Merge branch 'fix/is-transfer-for-cut-over' into 'development'
fix: override is_transfer lookup for direct cut-over laying flocks

See merge request mbugroup/lti-api!373
2026-03-17 18:44:11 +07:00
Adnan Zahir 2713210bcc fix: override is_transfer lookup for direct cut-over laying flocks 2026-03-17 18:41:51 +07:00
M1 AIR d76f72050e fix(migrations): restore missing project chickin bootstrap steps 2026-03-17 14:41:23 +07:00
Adnan Zahir 5d1eb60fb2 Merge branch 'dev/fifo-v2' into 'development'
Implement delete transfer to laying, chickin, and stock adjustment

See merge request mbugroup/lti-api!371
2026-03-17 11:07:52 +07:00
Adnan Zahir 5c36ef79cb Merge branch 'fix/is-depletion-hidden' into 'development'
fix: is depletion hidden

See merge request mbugroup/lti-api!370
2026-03-09 23:01:03 +07:00
Adnan Zahir da0ec225f1 fix: is depletion hidden attempt 2 2026-03-09 23:00:15 +07:00
Adnan Zahir a1b1841695 fix: is depletion hidden 2026-03-09 22:54:18 +07:00
Adnan Zahir cb9c202b29 Merge branch 'fix/prepare-stmt' into 'development'
[FIX/BE] Change from true to false, in database.go

See merge request mbugroup/lti-api!369
2026-03-09 21:55:14 +07:00
Hafizh A. Y ec7ec2219c fix: change from true to false, in database.go 2026-03-09 21:53:23 +07:00
Adnan Zahir b8164e2e08 Merge branch 'feat/kandang-groups' into 'development'
adjust employee

See merge request mbugroup/lti-api!368
2026-03-09 16:01:11 +07:00
giovanni 3c43426fbc adjust employee 2026-03-09 15:58:57 +07:00
Adnan Zahir 131aa4fcb5 Merge branch 'feat/kandang-groups' into 'development'
[FIX][BE]: adjust api delete group kandang

See merge request mbugroup/lti-api!367
2026-03-09 15:31:49 +07:00
giovanni c1acee7980 adjust api delete group kandang 2026-03-09 15:28:48 +07:00
Adnan Zahir 810aab1448 Merge branch 'feat/kandang-groups' into 'development'
[FEAT][BE]: create master data group kandangs

See merge request mbugroup/lti-api!365
2026-03-09 14:06:55 +07:00
giovanni b93150e8df adjust query daily checklist 2026-03-09 12:59:11 +07:00
giovanni fc532e1202 adjust kandangs 2026-03-09 12:05:14 +07:00
giovanni 71c7207bdb adjust 2026-03-09 11:32:38 +07:00
giovanni 96bef248cb Merge branch 'development' into feat/kandang-groups 2026-03-09 11:17:10 +07:00
giovanni 1dc1feed03 addjust permission 2026-03-09 11:15:53 +07:00
giovanni 5ebdc9dfe4 Adjust permission route 2026-03-09 11:12:38 +07:00
giovanni 65962f8e2b adjust const permission master data daily checkstlist kandang 2026-03-09 10:56:30 +07:00
giovanni 17ed266131 adjust master data kandang and query daily checklist to group kandangs 2026-03-09 10:39:07 +07:00
Adnan Zahir 26e782b212 Merge branch 'fix/param-master-product' into 'development'
fix: add include_all param in get all master product

See merge request mbugroup/lti-api!363
2026-03-09 00:51:39 +07:00
Hafizh A. Y 1fc6cb051d fix: add include_all param in get all master product 2026-03-09 00:47:37 +07:00
M1 AIR e6159e4f90 ci: use AWS ECR Public base images for lti builds 2026-03-08 23:55:38 +07:00
Adnan Zahir e80874bad7 Merge remote-tracking branch 'origin/dev/fifo-v2' into development 2026-03-08 23:40:39 +07:00
giovanni 566567e328 first commit add master data kandang group 2026-03-08 23:22:47 +07:00
M1 AIR 3cb2e15629 fix: defer fifo ayam migrations until seed exists 2026-03-08 20:00:22 +07:00
M1 AIR 1c5b013b9f fix: make fifo migrations order-safe for fresh setup 2026-03-08 19:53:24 +07:00
M1 AIR 83650d4486 fix: build migrate binary with postgres driver 2026-03-08 19:41:37 +07:00
M1 AIR 970332f0be chore: add db job manifests for argo dev-lti 2026-03-08 19:18:00 +07:00
Adnan Zahir f8ca404bbb Merge remote-tracking branch 'origin/dev/fifo-v2' into development 2026-03-08 15:07:59 +07:00
kris 73dfeb64b0 Merge branch 'test/mr-ci-clean' into 'development'
ci: use prod sha tag for mr image

See merge request mbugroup/lti-api!355
2026-03-04 17:54:01 +00:00
M1 AIR ee216327bb ci: use prod sha tag for mr image 2026-03-05 00:51:28 +07:00
kris 7fc52174e0 Merge branch 'test/mr-ci-clean' into 'development'
ci: force amd64 docker build platform

See merge request mbugroup/lti-api!353
2026-03-04 17:40:00 +00:00
M1 AIR 735c8e00d0 ci: force amd64 docker build platform 2026-03-05 00:39:08 +07:00
kris 1a1ea14824 Merge branch 'test/mr-ci-clean' into 'development'
ci: add dev gitops trigger after dev build

See merge request mbugroup/lti-api!352
2026-03-04 17:36:03 +00:00
M1 AIR ed1fb1b776 ci: add dev gitops trigger after dev build 2026-03-05 00:33:11 +07:00
kris 9e77d88c83 Merge branch 'test/mr-ci-clean' into 'development'
ci: align mr development production triggers

See merge request mbugroup/lti-api!351
2026-03-04 17:28:06 +00:00
M1 AIR 70db3cfd34 ci: align mr development production triggers 2026-03-05 00:27:05 +07:00
kris 562d8a90c8 Update .gitlab-ci.yml file 2026-03-04 17:15:34 +00:00
kris 68f77a97a5 Merge branch 'test/mr-ci' into 'development'
ci: use self-hosted-dev runner tags

See merge request mbugroup/lti-api!349
2026-03-04 17:09:38 +00:00
M1 AIR 54e4878406 ci: use self-hosted-dev runner tags 2026-03-05 00:08:50 +07:00
kris 7677057d7c Merge branch 'test/mr-ci' into 'development'
ci: adjust lti gitlab pipeline for ecr gitops

See merge request mbugroup/lti-api!348
2026-03-04 17:05:34 +00:00
M1 AIR 77ac46a029 ci: adjust lti gitlab pipeline for ecr gitops 2026-03-05 00:04:23 +07:00
kris cdd22cf198 Merge branch 'test/mr-ci' into 'development'
Test/mr ci

See merge request mbugroup/lti-api!347
2026-03-04 16:49:06 +00:00
M1 AIR 8a006f377e Update flow environment 2026-03-04 23:45:06 +07:00
kris 55451a5ea8 Update .gitlab-ci.yml file 2026-03-03 07:16:29 +00:00
kris aafbba199d Update .gitlab-ci.yml file 2026-03-03 07:06:05 +00:00
kris 0206d9dc68 Edit development.yml 2026-03-03 05:34:21 +00:00
kris e9d1cca294 Edit development.yml 2026-03-03 05:12:05 +00:00
Adnan Zahir 4d009978ae Merge branch 'fix/be/closing-unclosing' into 'development'
[FEAT/BE] fixing overhead,sapronak,perhitungan sapronak

See merge request mbugroup/lti-api!337
2026-02-26 11:30:01 +07:00
ragilap daca97f113 [FEAT/BE] fix return backend without payload logout 2026-02-26 11:17:13 +07:00
ragilap 88f1381f4b [FEAT/BE] fixing overhead,sapronak,perhitungan sapronak 2026-02-25 16:35:43 +07:00
Hafizh A. Y. 625642c709 Merge branch 'fix/be/24-feb-2026' into 'development'
[FEAT/BE] resolve jwks

See merge request mbugroup/lti-api!336
2026-02-25 03:28:41 +00:00
ragilap f6f4cc5a10 [FEAT/BE] resolve jwks 2026-02-24 15:16:09 +07:00
Hafizh A. Y. 5fb7a78a5a Merge branch 'feat/location' into 'development'
[FEAT][BE]: add query param filter has laying master data location

See merge request mbugroup/lti-api!334
2026-02-23 06:46:57 +00:00
Hafizh A. Y. bc1dac2a15 Merge branch 'fix/be/response-check-closing' into 'development'
[FEAT/BE] fix response check closing

See merge request mbugroup/lti-api!332
2026-02-23 06:44:10 +00:00
ragilap a3334c6bb0 [FEAT/BE] fixing approve status unclose 2026-02-23 11:56:53 +07:00
giovanni b73f13ee76 add query param filter has laying 2026-02-23 11:46:31 +07:00
ragilap 9d6a69dc4d [FEAT/BE] fix status closed project flock, closing perhitungan sapronak 2026-02-23 11:33:57 +07:00
ragilap 0ac174fdc6 [FEAT/BE] fix filter rejected delivery service 2026-02-22 21:12:57 +07:00
ragilap 4bf9b12680 [FEAT/BE] fix response closing and fix status rejected filter 2026-02-20 14:46:01 +07:00
ragilap 2028cee274 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/be/response-check-closing 2026-02-20 10:19:13 +07:00
ragilap 1c22c0f01c [FEAT/BE] fixing remaining stock check closing response 2026-02-20 10:19:00 +07:00
M1 AIR 18db58a87b test: MR pipeline notification 2026-02-07 00:14:58 +07:00
137 changed files with 12545 additions and 2096 deletions
+179 -28
View File
@@ -1,35 +1,186 @@
stages:
- build
- gitops
variables:
AWS_REGION: ap-southeast-3
ECR_REGISTRY: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com
ECR_REPO_NAME: mbugroup/lti-api
ECR_REPOSITORY: ${ECR_REGISTRY}/${ECR_REPO_NAME}
TARGET_PLATFORM: linux/amd64
DOCKER_HOST: unix:///var/run/docker.sock
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
workflow:
rules:
# MR pipeline
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: always
# Push pipeline hanya untuk env branch
- if: '$CI_COMMIT_BRANCH == "development"'
when: always
- if: '$CI_COMMIT_BRANCH == "staging"'
when: always
- if: '$CI_COMMIT_BRANCH == "production"'
when: always
# Selain itu jangan buat pipeline
# run untuk branch utama & MR
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- when: never
include:
# khusus MR (notif)
- local: "ci/merge_request.yml"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# =========================
# Helper: login ECR
# =========================
.ecr_login: &ecr_login |
AWS_CLI_ENV_ARGS=""
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}"
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
fi
# khusus push ke branch env
- local: "ci/development.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
ecr get-login-password --region "$AWS_REGION" || true)"
if [ -z "$PASS" ]; then
echo "ERROR: Failed to get ECR login password."
exit 1
fi
echo "$PASS" | docker login --username AWS --password-stdin "$ECR_REGISTRY"
- local: "ci/staging.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
# =========================
# MR
# =========================
build_mr:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
variables:
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *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..."
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
- local: "ci/production.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
# =========================
# DEVELOPMENT (push branch development)
# =========================
build_push_dev:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *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"
update_gitops_dev_lti:
stage: gitops
image: public.ecr.aws/docker/library/alpine:3.20
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
needs: ["build_push_dev"]
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
GITOPS_BRANCH: main
VALUES_FILE: environments/lti/dev/lti-values-dev.yaml
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
before_script:
- set -eu
- apk add --no-cache git yq
- git config --global user.email "ci@gitlab"
- git config --global user.name "gitlab-ci"
script: |
set -eu
rm -rf gitops
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
cd gitops
echo "Updating dev image.tag to $IMAGE_TAG"
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
git add "$VALUES_FILE"
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "lti dev deploy ${IMAGE_TAG}"
git push origin "$GITOPS_BRANCH"
# =========================
# PRODUCTION (push branch production)
# =========================
build_push_prod:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
variables:
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *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"
update_gitops_prod_lti:
stage: gitops
image: public.ecr.aws/docker/library/alpine:3.20
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
needs: ["build_push_prod"]
variables:
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
GITOPS_BRANCH: main
VALUES_FILE: environments/lti/prod/lti-values-prod.yaml
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
before_script:
- set -eu
- apk add --no-cache git yq
- git config --global user.email "ci@gitlab"
- git config --global user.name "gitlab-ci"
script: |
set -eu
rm -rf gitops
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
cd gitops
echo "Updating prod image.tag to $IMAGE_TAG"
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
git add "$VALUES_FILE"
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "lti prod deploy ${IMAGE_TAG}"
git push origin "$GITOPS_BRANCH"
+8 -3
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
@@ -15,14 +15,17 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api
# Build SEED binary (pastikan cmd/seed ada)
# Build SEED binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-seed ./cmd/seed
# Build migrate CLI with postgres + file drivers
RUN GOBIN=/usr/local/bin go install -tags "postgres file" -ldflags="-s -w" github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.3
# =========================
# 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
@@ -31,6 +34,8 @@ WORKDIR /app
COPY --from=builder /app/lti-api /app/lti-api
COPY --from=builder /app/lti-seed /app/lti-seed
COPY --from=builder /usr/local/bin/migrate /app/migrate
COPY --from=builder /app/internal/database/migrations /app/migrations
USER appuser
EXPOSE 8081
+1
View File
@@ -111,3 +111,4 @@ IT Development PT Mitra Berlian Unggas Group
## 📃 License
> This project is private. All rights reserved.
# mr test Sat 7 Feb 2026 00:14:58 WIB
+141 -73
View File
@@ -1,91 +1,159 @@
stages:
- deploy
- build
- gitops
deploy-dev:
stage: deploy
image: alpine:3.20
variables:
AWS_REGION: ap-southeast-3
ECR_REGISTRY: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com
ECR_REPO_NAME: mbugroup/lti-api
ECR_REPOSITORY: ${ECR_REGISTRY}/${ECR_REPO_NAME}
DOCKER_HOST: unix:///var/run/docker.sock
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
workflow:
rules:
- if: '$CI_COMMIT_BRANCH'
# =========================
# Helper: login ECR
# =========================
.ecr_login: &ecr_login |
AWS_CLI_ENV_ARGS=""
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}"
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
fi
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
ecr get-login-password --region "$AWS_REGION" || true)"
if [ -z "$PASS" ]; then
echo "ERROR: Failed to get ECR login password."
exit 1
fi
echo "$PASS" | docker login --username AWS --password-stdin "$ECR_REGISTRY"
# =========================
# DEV
# =========================
build_push_dev_lti:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
when: on_success
- when: never
variables:
DEPLOY_APP: "LTI-MBUGROUP"
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
before_script:
- echo "🧰 Installing dependencies..."
- apk update && apk add --no-cache openssh git curl bash
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
echo "Build & push: $ECR_REPOSITORY:$IMAGE_TAG"
# Setup SSH di runner
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa
docker build \
-t "$ECR_REPOSITORY:$IMAGE_TAG" \
.
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
update_gitops_dev_lti:
stage: gitops
image: public.ecr.aws/docker/library/alpine:3.20
tags: [self-hosted-dev]
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
needs: ["build_push_dev_lti"]
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
GITOPS_BRANCH: main
VALUES_FILE: environments/lti/dev/lti-values-dev.yaml
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
before_script:
- set -eu
- apk add --no-cache git yq
- git config --global user.email "ci@gitlab"
- git config --global user.name "gitlab-ci"
script: |
set -eu
rm -rf gitops
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
cd gitops
cd /home/devops/docker/deployment/development/lti-api
echo "Updating DEV image.tag to $IMAGE_TAG in $VALUES_FILE"
yq -i '.image.repository = strenv(ECR_REPOSITORY)' "$VALUES_FILE"
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
git add "$VALUES_FILE"
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "lti dev deploy ${IMAGE_TAG}"
git push origin "$GITOPS_BRANCH"
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
# =========================
# PROD
# =========================
# build_push_prod_lti:
# stage: build
# image: public.ecr.aws/docker/library/docker:27
# tags: [self-hosted-dev]
# rules:
# - if: '$CI_COMMIT_BRANCH == "production"'
# variables:
# IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
# before_script:
# - set -eu
# - docker version
# - docker info
# - *ecr_login
# script: |
# set -eu
# echo "Build & push: $ECR_REPOSITORY:$IMAGE_TAG"
# Fetch/reset pakai SSH
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
git reset --hard origin/development
# docker build \
# -t "$ECR_REPOSITORY:$IMAGE_TAG" \
# .
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
"; then
STATUS='success';
else
STATUS='failed';
fi;
# docker push "$ECR_REPOSITORY:$IMAGE_TAG"
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
# update_gitops_prod_lti:
# stage: gitops
# image: public.ecr.aws/docker/library/alpine:3.20
# tags: [self-hosted-dev]
# rules:
# - if: '$CI_COMMIT_BRANCH == "production"'
# needs: ["build_push_prod_lti"]
# variables:
# IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
# GITOPS_BRANCH: main
# VALUES_FILE: environments/lti/prod/lti-values-prod.yaml
# GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
# before_script:
# - set -eu
# - apk add --no-cache git yq
# - git config --global user.email "ci@gitlab"
# - git config --global user.name "gitlab-ci"
# script: |
# set -eu
# rm -rf gitops
# git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
# cd gitops
if [ "$STATUS" = "success" ]; then
COLOR=3066993;
TITLE="✅ Deployment API Succeeded";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully.";
else
COLOR=15158332;
TITLE="❌ Deployment API Failed Gaes";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed.";
fi;
# echo "Updating PROD image.tag to $IMAGE_TAG in $VALUES_FILE"
# yq -i '.image.repository = strenv(ECR_REPOSITORY)' "$VALUES_FILE"
# yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
echo "{
\"username\": \"CI Bot\",
\"embeds\": [{
\"title\": \"$TITLE\",
\"description\": \"$DESC\",
\"color\": $COLOR,
\"fields\": [
{\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true},
{\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true},
{\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false},
{\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false}
]
}]
}" > payload.json;
echo "📡 Sending notification to Discord...";
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
environment:
name: development
# git add "$VALUES_FILE"
# if git diff --cached --quiet; then
# echo "No changes to commit"
# exit 0
# fi
# git commit -m "lti prod deploy ${IMAGE_TAG}"
# git push origin "$GITOPS_BRANCH"
@@ -0,0 +1,297 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
const (
levelAllNoFlagProducts = 1
levelProductName = 2
levelProductWarehouse = 3
qtyEpsilon = 1e-6
)
type targetRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
CurrentQty float64 `gorm:"column:current_qty"`
ComputedQty float64 `gorm:"column:computed_qty"`
}
func main() {
var (
level int
productName string
productWarehouseID uint
apply bool
)
flag.IntVar(
&level,
"level",
levelAllNoFlagProducts,
"CLI level: 1=all products without flags, 2=specific product name (with flags), 3=specific product warehouse id",
)
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
productName = strings.TrimSpace(productName)
if err := validateFlags(level, productName, productWarehouseID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
targets, err := loadTargets(ctx, db, level, productName, productWarehouseID)
if err != nil {
log.Fatalf("failed to load target product warehouses: %v", err)
}
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
if productName != "" {
fmt.Printf("Filter product_name: %s\n", productName)
}
if productWarehouseID > 0 {
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
}
fmt.Printf("Targets found: %d\n\n", len(targets))
if len(targets) == 0 {
fmt.Println("No matching product warehouse rows to process")
return
}
for _, row := range targets {
fmt.Printf(
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f computed_qty=%.3f delta=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
row.ComputedQty-row.CurrentQty,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0\n", len(targets))
return
}
updated := 0
skipped := 0
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, row := range targets {
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
fmt.Printf(
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
row.ProductWarehouseID,
row.CurrentQty,
row.ComputedQty,
)
skipped++
continue
}
if err := tx.Table("product_warehouses").
Where("id = ?", row.ProductWarehouseID).
Update("qty", row.ComputedQty).Error; err != nil {
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
}
fmt.Printf(
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
)
updated++
}
return nil
})
if err != nil {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=1\n", len(targets), updated, skipped)
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=0\n", len(targets), updated, skipped)
}
func validateFlags(level int, productName string, productWarehouseID uint) error {
switch level {
case levelAllNoFlagProducts:
if productName != "" {
return errors.New("--product-name cannot be used on level 1")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 1")
}
case levelProductName:
if productName == "" {
return errors.New("--product-name is required on level 2")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 2")
}
case levelProductWarehouse:
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required on level 3")
}
if productName != "" {
return errors.New("--product-name cannot be used on level 3")
}
default:
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
}
return nil
}
func loadTargets(
ctx context.Context,
db *gorm.DB,
level int,
productName string,
productWarehouseID uint,
) ([]targetRow, error) {
switch level {
case levelAllNoFlagProducts:
return loadTargetsLevel1ByProductWithoutFlags(ctx, db)
case levelProductName:
return loadTargetsLevel2ByProductWarehouseWithFlags(ctx, db, productName)
case levelProductWarehouse:
return loadTargetByProductWarehouseID(ctx, db, productWarehouseID)
default:
return nil, fmt.Errorf("unsupported level %d", level)
}
}
func loadTargetsLevel1ByProductWithoutFlags(ctx context.Context, db *gorm.DB) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("p.deleted_at IS NULL").
Where("f.id IS NULL").
Group("pw.id, pw.product_id, p.name, pw.qty").
Order("pw.id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadTargetsLevel2ByProductWarehouseWithFlags(
ctx context.Context,
db *gorm.DB,
productName string,
) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("p.deleted_at IS NULL").
Where(`
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_id = p.id
AND f.flagable_type = ?
)
`, entity.FlagableTypeProduct).
Where("LOWER(p.name) = LOWER(?)", productName).
Group("pw.id, pw.product_id, p.name, pw.qty").
Order("pw.id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadTargetByProductWarehouseID(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.id = ?", productWarehouseID).
Group("pw.id, pw.product_id, p.name, pw.qty").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func levelLabel(level int) string {
switch level {
case levelAllNoFlagProducts:
return "all products without flags (source: purchase_items by product_warehouse_id)"
case levelProductName:
return "specific product name with flags (source: purchase_items by product_warehouse_id)"
case levelProductWarehouse:
return "specific product_warehouse_id (source: purchase_items by product_warehouse_id)"
default:
return "unknown"
}
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
package main
import (
"context"
"testing"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
)
func TestValidateAdjustmentGatherAgainstAllowedIDsEligible(t *testing.T) {
result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11, 12}, []commonSvc.FifoStockV2GatherRow{
{SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 70},
{SourceTable: "adjustment_stocks", SourceID: 12, AvailableQuantity: 40},
})
if result.Status != "eligible" {
t.Fatalf("expected eligible, got %+v", result)
}
if result.VerifiedQty != 100 {
t.Fatalf("expected verified qty 100, got %v", result.VerifiedQty)
}
}
func TestValidateAdjustmentGatherAgainstAllowedIDsRejectsMixedSource(t *testing.T) {
result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11}, []commonSvc.FifoStockV2GatherRow{
{SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 60},
{SourceTable: "recording_eggs", SourceID: 21, AvailableQuantity: 50},
})
if result.Status != "skipped" {
t.Fatalf("expected skipped, got %+v", result)
}
if result.Reason != "mixed_fifo_source_recording_eggs" {
t.Fatalf("unexpected reason: %+v", result)
}
}
func TestBuildAdjustmentMigrationPlanUsesValidator(t *testing.T) {
opts := &adjustmentCommandOptions{RunID: "egg-adjustment-cutover-test"}
farmID := uint(25)
farmName := "Gudang Farm Jamali"
rows := []adjustmentLegacyEggRow{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 101,
ProductID: 8,
ProductName: "Telur Utuh",
RemainingQty: 120,
CurrentPWQty: 150,
AdjustmentIDs: []uint{1},
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 102,
ProductID: 9,
ProductName: "Telur Putih",
RemainingQty: 20,
CurrentPWQty: 40,
AdjustmentIDs: []uint{2},
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
ProductWarehouseID: 103,
ProductID: 10,
ProductName: "Telur Pecah",
RemainingQty: 10,
CurrentPWQty: 10,
AdjustmentIDs: []uint{3},
},
}
validator := &fakeAdjustmentCandidateValidator{
byProduct: map[string]adjustmentCandidateValidation{
"Telur Utuh": {Status: "eligible", VerifiedQty: 120},
"Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10},
},
}
reportRows, groups := buildAdjustmentMigrationPlan(context.Background(), opts, map[uint]adjustmentLocationTiming{
16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"},
}, rows, validator)
if len(reportRows) != 3 {
t.Fatalf("expected 3 report rows, got %d", len(reportRows))
}
if len(groups) != 1 || len(groups[0].Rows) != 1 {
t.Fatalf("expected only one eligible grouped row, got %+v", groups)
}
if reportRows[0].Status != "eligible" || reportRows[0].VerifiedQty != 120 {
t.Fatalf("unexpected first row: %+v", reportRows[0])
}
if reportRows[1].Reason != "mixed_fifo_source_recording_eggs" {
t.Fatalf("unexpected second row reason: %+v", reportRows[1])
}
if reportRows[2].Reason != "missing_farm_warehouse" {
t.Fatalf("expected missing farm warehouse skip, got %+v", reportRows[2])
}
}
func TestExecuteAdjustmentApplyRevalidatesRowsAndAppliesSubset(t *testing.T) {
opts := &adjustmentCommandOptions{
RunID: "egg-adjustment-cutover-apply",
CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
ActorID: 99,
}
group := adjustmentTransferGroup{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: 25,
FarmWarehouseName: "Gudang Farm Jamali",
Rows: []*adjustmentMigrationReportRow{
{LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 101, ProductID: 8, ProductName: "Telur Utuh", RemainingQty: 120, CurrentPWQty: 150, AdjustmentIDs: []uint{1}, Status: "eligible"},
{LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 102, ProductID: 9, ProductName: "Telur Putih", RemainingQty: 20, CurrentPWQty: 40, AdjustmentIDs: []uint{2}, Status: "eligible"},
},
}
validator := &fakeAdjustmentCandidateValidator{
byProduct: map[string]adjustmentCandidateValidation{
"Telur Utuh": {Status: "eligible", VerifiedQty: 120},
"Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10},
},
}
executor := &fakeAdjustmentSystemTransferExecutor{
createResponses: []*entity.StockTransfer{
{Id: 1001, MovementNumber: "PND-LTI-1001"},
},
}
summary, err := executeAdjustmentApply(context.Background(), executor, validator, opts, []adjustmentTransferGroup{group})
if err != nil {
t.Fatalf("expected no fatal apply error, got %v", err)
}
if summary.GroupsApplied != 1 {
t.Fatalf("expected 1 applied group, got %+v", summary)
}
if summary.RowsApplied != 1 || summary.RowsFailed != 1 {
t.Fatalf("unexpected summary: %+v", summary)
}
if len(executor.createRequests) != 1 {
t.Fatalf("expected 1 create request, got %d", len(executor.createRequests))
}
if len(executor.createRequests[0].Products) != 1 || executor.createRequests[0].Products[0].ProductID != 8 {
t.Fatalf("expected only Telur Utuh to be transferred, got %+v", executor.createRequests[0].Products)
}
}
type fakeAdjustmentCandidateValidator struct {
byProduct map[string]adjustmentCandidateValidation
errByProduct map[string]error
}
func (f *fakeAdjustmentCandidateValidator) ValidateCandidate(ctx context.Context, row adjustmentLegacyEggRow) (adjustmentCandidateValidation, error) {
if err, ok := f.errByProduct[row.ProductName]; ok {
return adjustmentCandidateValidation{}, err
}
if result, ok := f.byProduct[row.ProductName]; ok {
return result, nil
}
return adjustmentCandidateValidation{Status: "eligible", VerifiedQty: row.RemainingQty}, nil
}
type fakeAdjustmentSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer
createErrors []error
deletedTransferIDs []uint
deleteErrors map[uint]error
}
func (f *fakeAdjustmentSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) {
f.createRequests = append(f.createRequests, req)
idx := len(f.createRequests) - 1
if idx < len(f.createErrors) && f.createErrors[idx] != nil {
return nil, f.createErrors[idx]
}
if idx < len(f.createResponses) && f.createResponses[idx] != nil {
return f.createResponses[idx], nil
}
return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil
}
func (f *fakeAdjustmentSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
if f.deleteErrors == nil {
return nil
}
return f.deleteErrors[id]
}
func uintPtr(v uint) *uint { return &v }
func strPtr(v string) *string { return &v }
var _ adjustmentCandidateValidator = (*fakeAdjustmentCandidateValidator)(nil)
var _ adjustmentSystemTransferExecutor = (*fakeAdjustmentSystemTransferExecutor)(nil)
var _ commonSvc.FifoStockV2Lane = fifoStockV2.LaneStockable
@@ -0,0 +1,825 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"sort"
"strings"
"text/tabwriter"
"time"
"github.com/go-playground/validator/v10"
"github.com/sirupsen/logrus"
"gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
pwRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
transferRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
pfkRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gorm.io/gorm"
)
const (
cutoverReasonPrefix = "EGG_FARM_CUTOVER"
outputModeTable = "table"
outputModeJSON = "json"
)
type commandOptions struct {
Apply bool
DryRun bool
RollbackRunID string
LocationID uint
LocationName string
CutoverDate time.Time
CutoverDateRaw string
IncludeOverlap bool
Output string
ActorID uint
RunID string
}
type locationTiming struct {
LocationID uint
LocationName string
FirstKandangDate *time.Time
LastKandangDate *time.Time
FirstFarmDate *time.Time
LastFarmDate *time.Time
Status string
}
type legacyEggStockRow struct {
LocationID uint
LocationName string
SourceWarehouseID uint
SourceWarehouseName string
FarmWarehouseID *uint
FarmWarehouseName *string
ProductWarehouseID uint
ProductID uint
ProductName string
OnHandQty float64
}
type migrationReportRow struct {
RunID string `json:"run_id"`
LocationID uint `json:"location_id"`
LocationName string `json:"location_name"`
SourceWarehouseID uint `json:"source_warehouse_id"`
SourceWarehouseName string `json:"source_warehouse_name"`
FarmWarehouseID *uint `json:"farm_warehouse_id,omitempty"`
FarmWarehouseName *string `json:"farm_warehouse_name,omitempty"`
ProductWarehouseID uint `json:"product_warehouse_id"`
ProductID uint `json:"product_id"`
ProductName string `json:"product_name"`
Qty float64 `json:"qty"`
LocationStatus string `json:"location_status"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
TransferID *uint64 `json:"transfer_id,omitempty"`
MovementNumber *string `json:"movement_number,omitempty"`
}
type applySummary struct {
RowsPlanned int `json:"rows_planned"`
RowsApplied int `json:"rows_applied"`
RowsSkipped int `json:"rows_skipped"`
RowsFailed int `json:"rows_failed"`
GroupsPlanned int `json:"groups_planned"`
GroupsApplied int `json:"groups_applied"`
}
type rollbackDetailRow struct {
RunID string `json:"run_id"`
TransferID uint64 `json:"transfer_id"`
MovementNumber string `json:"movement_number"`
LocationName string `json:"location_name"`
SourceWarehouseName string `json:"source_warehouse_name"`
FarmWarehouseName string `json:"farm_warehouse_name"`
ProductName string `json:"product_name"`
Qty float64 `json:"qty"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
}
type systemTransferExecutor interface {
CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
}
type transferGroup struct {
LocationID uint
LocationName string
SourceWarehouseID uint
SourceWarehouseName string
FarmWarehouseID uint
FarmWarehouseName string
Rows []*migrationReportRow
}
func main() {
opts, err := parseFlags()
if err != nil {
log.Fatalf("invalid flags: %v", err)
}
db := database.Connect(config.DBHost, config.DBName)
ctx := context.Background()
if strings.TrimSpace(opts.RollbackRunID) != "" {
rows, err := loadRollbackDetails(ctx, db, opts.RollbackRunID)
if err != nil {
log.Fatalf("failed to load rollback details: %v", err)
}
if !opts.Apply {
for i := range rows {
rows[i].Status = "eligible"
}
renderRollbackReport(opts.Output, rows)
return
}
if err := executeRollback(ctx, newSystemTransferService(db), rows, opts.ActorID); err != nil {
log.Fatalf("rollback failed: %v", err)
}
renderRollbackReport(opts.Output, rows)
return
}
timings, err := loadLocationTimings(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load location timings: %v", err)
}
legacyRows, err := loadLegacyEggStocks(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load legacy egg stocks: %v", err)
}
reportRows, groups := buildMigrationPlan(opts, timings, legacyRows)
if !opts.Apply {
renderMigrationReport(opts.Output, reportRows, summarizeApply(reportRows, groups, 0))
return
}
summary, err := executeApply(ctx, newSystemTransferService(db), opts, groups)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
finalRows := flattenGroups(groups, reportRows)
summary = summarizeApply(finalRows, groups, summary.GroupsApplied)
renderMigrationReport(opts.Output, finalRows, summary)
}
func parseFlags() (*commandOptions, error) {
var opts commandOptions
flag.BoolVar(&opts.Apply, "apply", false, "Apply migration. If false, run as dry-run")
flag.BoolVar(&opts.DryRun, "dry-run", true, "Run as dry-run")
flag.StringVar(&opts.RollbackRunID, "rollback-run-id", "", "Rollback all transfers created by the provided run id")
flag.UintVar(&opts.LocationID, "location-id", 0, "Filter by location id")
flag.StringVar(&opts.LocationName, "location-name", "", "Filter by exact location name")
flag.StringVar(&opts.CutoverDateRaw, "cutover-date", "", "Cutover date in YYYY-MM-DD format")
flag.BoolVar(&opts.IncludeOverlap, "include-overlap", false, "Include overlap locations in plan/apply")
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
flag.UintVar(&opts.ActorID, "actor-id", 1, "Actor id used for created/deleted transfers")
flag.Parse()
opts.LocationName = strings.TrimSpace(opts.LocationName)
opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID)
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
if opts.Output == "" {
opts.Output = outputModeTable
}
if opts.Output != outputModeTable && opts.Output != outputModeJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
if opts.Apply {
opts.DryRun = false
}
if opts.LocationID > 0 && opts.LocationName != "" {
return nil, errors.New("use either --location-id or --location-name, not both")
}
if opts.RollbackRunID != "" {
if opts.LocationID > 0 || opts.LocationName != "" {
return nil, errors.New("location filters are not supported with --rollback-run-id")
}
if opts.CutoverDateRaw != "" {
return nil, errors.New("--cutover-date is not used with --rollback-run-id")
}
} else if opts.Apply {
if opts.LocationID == 0 && opts.LocationName == "" {
return nil, errors.New("apply mode requires --location-id or --location-name for safety")
}
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
return nil, errors.New("--cutover-date is required in apply mode")
}
}
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
opts.CutoverDate = normalizeDateOnly(time.Now().In(time.FixedZone("Asia/Jakarta", 7*3600)))
} else {
t, err := time.Parse("2006-01-02", opts.CutoverDateRaw)
if err != nil {
return nil, fmt.Errorf("invalid --cutover-date: %w", err)
}
opts.CutoverDate = normalizeDateOnly(t)
}
opts.RunID = buildRunID()
return &opts, nil
}
func newSystemTransferService(db *gorm.DB) systemTransferExecutor {
validate := validator.New()
stockTransferRepo := transferRepo.NewStockTransferRepository(db)
stockTransferDetailRepo := transferRepo.NewStockTransferDetailRepository(db)
stockTransferDeliveryRepo := transferRepo.NewStockTransferDeliveryRepository(db)
stockTransferDeliveryItemRepo := transferRepo.NewStockTransferDeliveryItemRepository(db)
stockLogsRepo := stockLogRepo.NewStockLogRepository(db)
productWarehouseRepo := pwRepo.NewProductWarehouseRepository(db)
warehouseRepository := warehouseRepo.NewWarehouseRepository(db)
projectFlockKandangRepo := pfkRepo.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := pfkRepo.NewProjectFlockPopulationRepository(db)
fifoSvc := service.NewFifoStockV2Service(db, logrus.StandardLogger())
return transferSvc.NewTransferService(
validate,
stockTransferRepo,
stockTransferDetailRepo,
stockTransferDeliveryRepo,
stockTransferDeliveryItemRepo,
stockLogsRepo,
productWarehouseRepo,
nil,
warehouseRepository,
projectFlockKandangRepo,
projectFlockPopulationRepo,
nil,
fifoSvc,
nil,
)
}
func loadLocationTimings(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]locationTiming, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"`
FirstKandangDate *time.Time `gorm:"column:first_kandang_date"`
LastKandangDate *time.Time `gorm:"column:last_kandang_date"`
FirstFarmDate *time.Time `gorm:"column:first_farm_date"`
LastFarmDate *time.Time `gorm:"column:last_farm_date"`
}
query := db.WithContext(ctx).
Table("recording_eggs re").
Select(`
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
`).
Joins("JOIN recordings r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)").
Joins("JOIN project_flocks pf ON pf.id = pk.project_flock_id").
Joins("JOIN locations l ON l.id = pf.location_id").
Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Group("pf.location_id, l.name")
query = applyTimingLocationFilter(query, opts)
var rows []row
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]locationTiming, len(rows))
for _, row := range rows {
status := "KANDANG_ONLY"
if row.FirstFarmDate != nil {
status = "OVERLAP"
if row.LastKandangDate == nil || row.FirstFarmDate.After(normalizeDateOnly(*row.LastKandangDate)) {
status = "CLEAN_CUTOVER"
}
}
result[row.LocationID] = locationTiming{
LocationID: row.LocationID,
LocationName: row.LocationName,
FirstKandangDate: normalizeDatePtr(row.FirstKandangDate),
LastKandangDate: normalizeDatePtr(row.LastKandangDate),
FirstFarmDate: normalizeDatePtr(row.FirstFarmDate),
LastFarmDate: normalizeDatePtr(row.LastFarmDate),
Status: status,
}
}
return result, nil
}
func loadLegacyEggStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]legacyEggStockRow, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"`
SourceWarehouseID uint `gorm:"column:source_warehouse_id"`
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
FarmWarehouseID *uint `gorm:"column:farm_warehouse_id"`
FarmWarehouseName *string `gorm:"column:farm_warehouse_name"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
OnHandQty float64 `gorm:"column:on_hand_qty"`
}
firstFarmSub := db.WithContext(ctx).
Table("warehouses fw").
Select("fw.location_id AS location_id, MIN(fw.id) AS farm_warehouse_id").
Where("fw.deleted_at IS NULL").
Where("fw.type = ?", "LOKASI").
Group("fw.location_id")
query := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
kw.location_id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
`).
Joins("JOIN warehouses kw ON kw.id = pw.warehouse_id AND kw.deleted_at IS NULL").
Joins("JOIN locations l ON l.id = kw.location_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN product_categories pc ON pc.id = p.product_category_id").
Joins("LEFT JOIN (?) ff ON ff.location_id = kw.location_id", firstFarmSub).
Joins("LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id").
Where("kw.type = ?", "KANDANG").
Where(`
EXISTS (
SELECT 1
FROM recording_eggs re
WHERE re.product_warehouse_id = pw.id
)
`).
Where(`
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_type = ?
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = ?
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
`, entity.FlagableTypeProduct, entity.FlagableTypeProduct).
Order("l.name ASC, kw.name ASC, p.name ASC")
query = applyLegacyStockLocationFilter(query, opts)
var rows []row
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
result := make([]legacyEggStockRow, 0, len(rows))
for _, row := range rows {
result = append(result, legacyEggStockRow{
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: row.FarmWarehouseID,
FarmWarehouseName: row.FarmWarehouseName,
ProductWarehouseID: row.ProductWarehouseID,
ProductID: row.ProductID,
ProductName: row.ProductName,
OnHandQty: row.OnHandQty,
})
}
return result, nil
}
func buildMigrationPlan(
opts *commandOptions,
timings map[uint]locationTiming,
rows []legacyEggStockRow,
) ([]migrationReportRow, []transferGroup) {
reportRows := make([]migrationReportRow, 0, len(rows))
groupMap := make(map[string]*transferGroup)
for _, row := range rows {
locationStatus := "UNKNOWN"
if timing, ok := timings[row.LocationID]; ok {
locationStatus = timing.Status
}
report := migrationReportRow{
RunID: opts.RunID,
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: row.FarmWarehouseID,
FarmWarehouseName: row.FarmWarehouseName,
ProductWarehouseID: row.ProductWarehouseID,
ProductID: row.ProductID,
ProductName: row.ProductName,
Qty: row.OnHandQty,
LocationStatus: locationStatus,
Status: "eligible",
}
switch {
case row.FarmWarehouseID == nil || row.FarmWarehouseName == nil:
report.Status = "skipped"
report.Reason = "missing_farm_warehouse"
case row.OnHandQty <= 0:
report.Status = "skipped"
report.Reason = "non_positive_qty"
case locationStatus == "OVERLAP" && !opts.IncludeOverlap:
report.Status = "skipped"
report.Reason = "overlap_location"
}
reportRows = append(reportRows, report)
if report.Status != "eligible" {
continue
}
groupKey := fmt.Sprintf("%d:%d", row.SourceWarehouseID, *row.FarmWarehouseID)
group := groupMap[groupKey]
if group == nil {
group = &transferGroup{
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: *row.FarmWarehouseID,
FarmWarehouseName: derefString(row.FarmWarehouseName),
}
groupMap[groupKey] = group
}
group.Rows = append(group.Rows, &reportRows[len(reportRows)-1])
}
groups := make([]transferGroup, 0, len(groupMap))
for _, group := range groupMap {
sort.Slice(group.Rows, func(i, j int) bool {
return group.Rows[i].ProductName < group.Rows[j].ProductName
})
groups = append(groups, *group)
}
sort.Slice(groups, func(i, j int) bool {
if groups[i].LocationName == groups[j].LocationName {
return groups[i].SourceWarehouseName < groups[j].SourceWarehouseName
}
return groups[i].LocationName < groups[j].LocationName
})
return reportRows, groups
}
func executeApply(
ctx context.Context,
svc systemTransferExecutor,
opts *commandOptions,
groups []transferGroup,
) (applySummary, error) {
summary := applySummary{GroupsPlanned: len(groups)}
for _, group := range groups {
products := make([]transferSvc.SystemTransferProduct, 0, len(group.Rows))
for _, row := range group.Rows {
products = append(products, transferSvc.SystemTransferProduct{
ProductID: row.ProductID,
ProductQty: row.Qty,
})
}
reason := buildCutoverReason(opts.RunID, group.LocationName, opts.CutoverDate)
transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{
TransferReason: reason,
TransferDate: opts.CutoverDate,
SourceWarehouseID: group.SourceWarehouseID,
DestinationWarehouseID: group.FarmWarehouseID,
Products: products,
ActorID: opts.ActorID,
StockLogNotes: reason,
})
if err != nil {
for _, row := range group.Rows {
row.Status = "failed"
row.Reason = err.Error()
summary.RowsFailed++
}
continue
}
summary.GroupsApplied++
for _, row := range group.Rows {
row.Status = "applied"
row.TransferID = &transfer.Id
row.MovementNumber = &transfer.MovementNumber
summary.RowsApplied++
}
}
for _, group := range groups {
summary.RowsPlanned += len(group.Rows)
}
return summary, nil
}
func executeRollback(
ctx context.Context,
svc systemTransferExecutor,
rows []rollbackDetailRow,
actorID uint,
) error {
if actorID == 0 {
return fmt.Errorf("actor id is required for rollback")
}
byTransfer := make(map[uint64][]int)
for idx, row := range rows {
byTransfer[row.TransferID] = append(byTransfer[row.TransferID], idx)
}
transferIDs := make([]uint64, 0, len(byTransfer))
for transferID := range byTransfer {
transferIDs = append(transferIDs, transferID)
}
sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] })
var firstErr error
for _, transferID := range transferIDs {
err := svc.DeleteSystemTransfer(ctx, uint(transferID), actorID)
for _, idx := range byTransfer[transferID] {
if err != nil {
rows[idx].Status = "failed"
rows[idx].Reason = err.Error()
} else {
rows[idx].Status = "rolled_back"
}
}
if err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) {
type row struct {
TransferID uint64 `gorm:"column:transfer_id"`
MovementNumber string `gorm:"column:movement_number"`
LocationName string `gorm:"column:location_name"`
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
FarmWarehouseName string `gorm:"column:farm_warehouse_name"`
ProductName string `gorm:"column:product_name"`
Qty float64 `gorm:"column:qty"`
}
needle := buildRunReasonMatcher(runID)
var dbRows []row
err := db.WithContext(ctx).
Table("stock_transfers st").
Select(`
st.id AS transfer_id,
st.movement_number AS movement_number,
COALESCE(loc.name, '') AS location_name,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS qty
`).
Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id").
Joins("JOIN warehouses wd ON wd.id = st.to_warehouse_id").
Joins("LEFT JOIN locations loc ON loc.id = COALESCE(ws.location_id, wd.location_id)").
Joins("JOIN stock_transfer_details std ON std.stock_transfer_id = st.id AND std.deleted_at IS NULL").
Joins("JOIN products p ON p.id = std.product_id").
Where("st.deleted_at IS NULL").
Where("st.reason LIKE ?", needle).
Order("st.id DESC, std.id ASC").
Scan(&dbRows).Error
if err != nil {
return nil, err
}
rows := make([]rollbackDetailRow, 0, len(dbRows))
for _, row := range dbRows {
rows = append(rows, rollbackDetailRow{
RunID: runID,
TransferID: row.TransferID,
MovementNumber: row.MovementNumber,
LocationName: row.LocationName,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseName: row.FarmWarehouseName,
ProductName: row.ProductName,
Qty: row.Qty,
})
}
return rows, nil
}
func applyTimingLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
if opts == nil {
return db
}
switch {
case opts.LocationID > 0:
return db.Where("pf.location_id = ?", opts.LocationID)
case opts.LocationName != "":
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
default:
return db
}
}
func applyLegacyStockLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
if opts == nil {
return db
}
switch {
case opts.LocationID > 0:
return db.Where("kw.location_id = ?", opts.LocationID)
case opts.LocationName != "":
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
default:
return db
}
}
func buildCutoverReason(runID, locationName string, cutoverDate time.Time) string {
locationName = strings.ReplaceAll(strings.TrimSpace(locationName), "|", "/")
return fmt.Sprintf("%s|run_id=%s|location=%s|cutover_date=%s", cutoverReasonPrefix, runID, locationName, cutoverDate.Format("2006-01-02"))
}
func buildRunReasonMatcher(runID string) string {
return fmt.Sprintf("%s|run_id=%s|%%", cutoverReasonPrefix, strings.TrimSpace(runID))
}
func buildRunID() string {
return fmt.Sprintf("egg-cutover-%s", time.Now().UTC().Format("20060102T150405.000000000Z"))
}
func normalizeDateOnly(value time.Time) time.Time {
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC)
}
func normalizeDatePtr(value *time.Time) *time.Time {
if value == nil {
return nil
}
normalized := normalizeDateOnly(*value)
return &normalized
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func summarizeApply(rows []migrationReportRow, groups []transferGroup, appliedGroups int) applySummary {
summary := applySummary{
GroupsPlanned: len(groups),
GroupsApplied: appliedGroups,
}
for _, row := range rows {
switch row.Status {
case "eligible":
summary.RowsPlanned++
case "applied":
summary.RowsPlanned++
summary.RowsApplied++
case "failed":
summary.RowsPlanned++
summary.RowsFailed++
case "skipped":
summary.RowsSkipped++
}
}
return summary
}
func flattenGroups(groups []transferGroup, fallback []migrationReportRow) []migrationReportRow {
if len(groups) == 0 {
return fallback
}
rows := make([]migrationReportRow, 0, len(fallback))
for _, group := range groups {
for _, row := range group.Rows {
rows = append(rows, *row)
}
}
for _, row := range fallback {
if row.Status == "skipped" {
rows = append(rows, row)
}
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].LocationName == rows[j].LocationName {
if rows[i].SourceWarehouseName == rows[j].SourceWarehouseName {
return rows[i].ProductName < rows[j].ProductName
}
return rows[i].SourceWarehouseName < rows[j].SourceWarehouseName
}
return rows[i].LocationName < rows[j].LocationName
})
return rows
}
func renderMigrationReport(mode string, rows []migrationReportRow, summary applySummary) {
if mode == outputModeJSON {
payload := map[string]any{
"rows": rows,
"summary": summary,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tLOCATION_STATUS\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER")
for _, row := range rows {
transferID := "-"
if row.TransferID != nil {
transferID = fmt.Sprintf("%d", *row.TransferID)
}
movementNumber := "-"
if row.MovementNumber != nil {
movementNumber = *row.MovementNumber
}
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\t%s\t%s\t%s\n",
row.RunID,
row.LocationName,
row.SourceWarehouseName,
derefString(row.FarmWarehouseName),
row.ProductName,
row.Qty,
row.LocationStatus,
row.Status,
row.Reason,
transferID,
movementNumber,
)
}
_ = w.Flush()
fmt.Printf("\nSummary: rows_planned=%d rows_applied=%d rows_skipped=%d rows_failed=%d groups_planned=%d groups_applied=%d\n",
summary.RowsPlanned, summary.RowsApplied, summary.RowsSkipped, summary.RowsFailed, summary.GroupsPlanned, summary.GroupsApplied)
}
func renderRollbackReport(mode string, rows []rollbackDetailRow) {
if mode == outputModeJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]any{"rows": rows})
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "RUN_ID\tTRANSFER_ID\tMOVEMENT_NUMBER\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tSTATUS\tREASON")
for _, row := range rows {
fmt.Fprintf(
w,
"%s\t%d\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\n",
row.RunID,
row.TransferID,
row.MovementNumber,
row.LocationName,
row.SourceWarehouseName,
row.FarmWarehouseName,
row.ProductName,
row.Qty,
row.Status,
row.Reason,
)
}
_ = w.Flush()
}
@@ -0,0 +1,251 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
)
func TestBuildMigrationPlanSkipsOverlapAndGroupsEligibleRows(t *testing.T) {
opts := &commandOptions{
RunID: "egg-cutover-test",
IncludeOverlap: false,
}
timings := map[uint]locationTiming{
16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"},
17: {LocationID: 17, LocationName: "Cijangkar", Status: "OVERLAP"},
}
farmID := uint(25)
farmName := "Gudang Farm Jamali"
rows := []legacyEggStockRow{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 101,
ProductID: 8,
ProductName: "Telur Utuh",
OnHandQty: 120,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 102,
ProductID: 9,
ProductName: "Telur Putih",
OnHandQty: 20,
},
{
LocationID: 17,
LocationName: "Cijangkar",
SourceWarehouseID: 51,
SourceWarehouseName: "Gudang Cijangkar 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 103,
ProductID: 10,
ProductName: "Telur Jumbo",
OnHandQty: 10,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
ProductWarehouseID: 104,
ProductID: 11,
ProductName: "Telur Papacal",
OnHandQty: 50,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 105,
ProductID: 12,
ProductName: "Telur Retak",
OnHandQty: 0,
},
}
reportRows, groups := buildMigrationPlan(opts, timings, rows)
if len(reportRows) != 5 {
t.Fatalf("expected 5 report rows, got %d", len(reportRows))
}
if len(groups) != 1 {
t.Fatalf("expected 1 eligible transfer group, got %d", len(groups))
}
if len(groups[0].Rows) != 2 {
t.Fatalf("expected 2 eligible products in the transfer group, got %d", len(groups[0].Rows))
}
statusByProduct := make(map[string]string, len(reportRows))
reasonByProduct := make(map[string]string, len(reportRows))
for _, row := range reportRows {
statusByProduct[row.ProductName] = row.Status
reasonByProduct[row.ProductName] = row.Reason
}
if statusByProduct["Telur Utuh"] != "eligible" || statusByProduct["Telur Putih"] != "eligible" {
t.Fatalf("expected Jamali egg rows to stay eligible, got statuses %+v", statusByProduct)
}
if reasonByProduct["Telur Jumbo"] != "overlap_location" {
t.Fatalf("expected overlap location skip, got %q", reasonByProduct["Telur Jumbo"])
}
if reasonByProduct["Telur Papacal"] != "missing_farm_warehouse" {
t.Fatalf("expected missing farm warehouse skip, got %q", reasonByProduct["Telur Papacal"])
}
if reasonByProduct["Telur Retak"] != "non_positive_qty" {
t.Fatalf("expected non positive qty skip, got %q", reasonByProduct["Telur Retak"])
}
}
func TestExecuteApplyBuildsTaggedSystemTransfersAndSummaries(t *testing.T) {
opts := &commandOptions{
RunID: "egg-cutover-apply",
CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
ActorID: 99,
}
groups := []transferGroup{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: 25,
FarmWarehouseName: "Gudang Farm Jamali",
Rows: []*migrationReportRow{
{ProductID: 8, ProductName: "Telur Utuh", Qty: 120},
{ProductID: 9, ProductName: "Telur Putih", Qty: 20},
},
},
{
LocationID: 18,
LocationName: "Tamansari",
SourceWarehouseID: 91,
SourceWarehouseName: "Gudang Tamansari 1",
FarmWarehouseID: 31,
FarmWarehouseName: "Gudang Farm Tamansari",
Rows: []*migrationReportRow{
{ProductID: 10, ProductName: "Telur Jumbo", Qty: 10},
},
},
}
executor := &fakeSystemTransferExecutor{
createResponses: []*entity.StockTransfer{
{Id: 1001, MovementNumber: "PND-LTI-1001"},
},
createErrors: []error{
nil,
errors.New("destination warehouse locked"),
},
}
summary, err := executeApply(context.Background(), executor, opts, groups)
if err != nil {
t.Fatalf("expected no fatal apply error, got %v", err)
}
if summary.GroupsPlanned != 2 || summary.GroupsApplied != 1 {
t.Fatalf("unexpected group summary: %+v", summary)
}
if summary.RowsApplied != 2 || summary.RowsFailed != 1 {
t.Fatalf("unexpected row summary: %+v", summary)
}
if len(executor.createRequests) != 2 {
t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests))
}
if !strings.Contains(executor.createRequests[0].TransferReason, "EGG_FARM_CUTOVER|run_id=egg-cutover-apply|location=Jamali|cutover_date=2026-04-07") {
t.Fatalf("unexpected transfer reason: %s", executor.createRequests[0].TransferReason)
}
if executor.createRequests[0].MovementNumber != "" {
t.Fatalf("apply path should let transfer service generate movement number, got %q", executor.createRequests[0].MovementNumber)
}
if groups[0].Rows[0].Status != "applied" || groups[0].Rows[1].Status != "applied" {
t.Fatalf("expected first group rows to be applied, got %+v", groups[0].Rows)
}
if groups[1].Rows[0].Status != "failed" {
t.Fatalf("expected second group row to fail, got %+v", groups[1].Rows[0])
}
if groups[0].Rows[0].TransferID == nil || *groups[0].Rows[0].TransferID != 1001 {
t.Fatalf("expected first row to keep created transfer id, got %+v", groups[0].Rows[0].TransferID)
}
}
func TestExecuteRollbackDeletesTransfersDescendingAndMarksFailures(t *testing.T) {
executor := &fakeSystemTransferExecutor{
deleteErrors: map[uint]error{
101: errors.New("already consumed downstream"),
},
}
rows := []rollbackDetailRow{
{TransferID: 100, ProductName: "Telur Utuh"},
{TransferID: 101, ProductName: "Telur Jumbo"},
{TransferID: 100, ProductName: "Telur Putih"},
}
err := executeRollback(context.Background(), executor, rows, 99)
if err == nil {
t.Fatal("expected rollback to return the first transfer error")
}
if err.Error() != "already consumed downstream" {
t.Fatalf("unexpected rollback error: %v", err)
}
if len(executor.deletedTransferIDs) != 2 {
t.Fatalf("expected 2 delete calls, got %d", len(executor.deletedTransferIDs))
}
if executor.deletedTransferIDs[0] != 101 || executor.deletedTransferIDs[1] != 100 {
t.Fatalf("expected delete order [101 100], got %v", executor.deletedTransferIDs)
}
if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" {
t.Fatalf("expected transfer 100 rows to be rolled back, got %+v", rows)
}
if rows[1].Status != "failed" {
t.Fatalf("expected transfer 101 row to fail, got %+v", rows[1])
}
}
type fakeSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer
createErrors []error
deletedTransferIDs []uint
deleteErrors map[uint]error
}
func (f *fakeSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) {
f.createRequests = append(f.createRequests, req)
idx := len(f.createRequests) - 1
if idx < len(f.createErrors) && f.createErrors[idx] != nil {
return nil, f.createErrors[idx]
}
if idx < len(f.createResponses) && f.createResponses[idx] != nil {
return f.createResponses[idx], nil
}
return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil
}
func (f *fakeSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
if f.deleteErrors == nil {
return nil
}
return f.deleteErrors[id]
}
@@ -0,0 +1,282 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
const qtyEpsilon = 1e-6
const (
levelAll = 1
levelByProductName = 2
levelByProductWarehouse = 3
)
type reflowRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
CurrentQty float64 `gorm:"column:current_qty"`
SumTotalQty float64 `gorm:"column:sum_total_qty"`
SumAllocatedQty float64 `gorm:"column:sum_allocated_qty"`
ComputedQty float64 `gorm:"column:computed_qty"`
}
func main() {
var (
apply bool
level int
productName string
productWarehouseID uint
)
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.IntVar(&level, "level", levelAll, "CLI level: 1=all product_warehouse scope, 2=product name scope, 3=product_warehouse_id scope")
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
flag.Parse()
productName = strings.TrimSpace(productName)
if err := validateFlags(level, productName, productWarehouseID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
rows, err := loadReflowRows(ctx, db, level, productName, productWarehouseID)
if err != nil {
log.Fatalf("failed to calculate reflow qty: %v", err)
}
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
if productName != "" {
fmt.Printf("Filter product_name: %s\n", productName)
}
if productWarehouseID > 0 {
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
}
fmt.Printf("Targets found: %d\n\n", len(rows))
if len(rows) == 0 {
fmt.Println("No product warehouse found from purchase_items scope")
return
}
negativePlan := 0
for _, row := range rows {
if row.ComputedQty < 0 {
negativePlan++
}
fmt.Printf(
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f total_qty=%.3f allocated_qty=%.3f computed_qty=%.3f delta=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.SumTotalQty,
row.SumAllocatedQty,
row.ComputedQty,
row.ComputedQty-row.CurrentQty,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0 negative_plan=%d\n", len(rows), negativePlan)
return
}
updated := 0
skipped := 0
negativeUpdated := 0
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, row := range rows {
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
fmt.Printf(
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
row.ProductWarehouseID,
row.CurrentQty,
row.ComputedQty,
)
skipped++
continue
}
if err := tx.Table("product_warehouses").
Where("id = ?", row.ProductWarehouseID).
Update("qty", row.ComputedQty).Error; err != nil {
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
}
if row.ComputedQty < 0 {
negativeUpdated++
}
fmt.Printf(
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
)
updated++
}
return nil
})
if err != nil {
fmt.Println()
fmt.Printf(
"Summary: planned=%d updated=%d skipped=%d failed=1 negative_plan=%d negative_updated=%d\n",
len(rows),
updated,
skipped,
negativePlan,
negativeUpdated,
)
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Printf(
"Summary: planned=%d updated=%d skipped=%d failed=0 negative_plan=%d negative_updated=%d\n",
len(rows),
updated,
skipped,
negativePlan,
negativeUpdated,
)
}
func validateFlags(level int, productName string, productWarehouseID uint) error {
switch level {
case levelAll:
if productName != "" {
return errors.New("--product-name cannot be used on level 1")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 1")
}
case levelByProductName:
if productName == "" {
return errors.New("--product-name is required on level 2")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 2")
}
case levelByProductWarehouse:
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required on level 3")
}
if productName != "" {
return errors.New("--product-name cannot be used on level 3")
}
default:
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
}
return nil
}
func loadReflowRows(
ctx context.Context,
db *gorm.DB,
level int,
productName string,
productWarehouseID uint,
) ([]reflowRow, error) {
allocSub := db.WithContext(ctx).
Table("stock_allocations sa").
Select(`
sa.stockable_id,
COALESCE(SUM(sa.qty), 0) AS used_qty
`).
Where("sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.deleted_at IS NULL").
Group("sa.stockable_id")
calcSub := db.WithContext(ctx).
Table("purchase_items pi").
Select(`
pi.product_warehouse_id,
COALESCE(SUM(pi.total_qty), 0) AS sum_total_qty,
COALESCE(SUM(COALESCE(alloc.used_qty, 0)), 0) AS sum_allocated_qty,
COALESCE(SUM(COALESCE(pi.total_qty, 0) - COALESCE(alloc.used_qty, 0)), 0) AS computed_qty
`).
Joins("LEFT JOIN (?) alloc ON alloc.stockable_id = pi.id", allocSub).
Where("pi.product_warehouse_id IS NOT NULL").
Group("pi.product_warehouse_id")
query := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
calc.sum_total_qty,
calc.sum_allocated_qty,
calc.computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN (?) calc ON calc.product_warehouse_id = pw.id", calcSub).
Order("pw.id ASC")
switch level {
case levelByProductName:
query = query.Where("LOWER(p.name) = LOWER(?)", productName)
case levelByProductWarehouse:
query = query.Where("pw.id = ?", productWarehouseID)
}
rows := make([]reflowRow, 0)
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func levelLabel(level int) string {
switch level {
case levelAll:
return "all product_warehouse from purchase_items"
case levelByProductName:
return "specific product name"
case levelByProductWarehouse:
return "specific product_warehouse_id"
default:
return "unknown"
}
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}
+31
View File
@@ -0,0 +1,31 @@
# Farm Stock Attribution Design Note
## Goal
Allow farm-level physical stock to be used directly by kandang-level operations without forcing transfers, while keeping kandang attribution, FIFO-v2 compatibility, traceability, and HPP/COGS intact.
## Core Model
- Physical stock stays on the real `product_warehouse_id` that was consumed or received.
- Kandang attribution comes from the transaction or allocation path, not from `product_warehouses.project_flock_kandang_id`.
- Existing kandang-bound warehouses remain valid for historical and current kandang-only flows.
- Shared farm warehouses must stay shareable; application code must stop silently converting them into kandang-owned warehouses.
## Attribution Rules
- `recording_stocks`: consumer kandang is the parent `recordings.project_flock_kandangs_id`; physical stock source remains `recording_stocks.product_warehouse_id`.
- `recording_depletions`: source kandang is the recording kandang and is stored explicitly for compatibility; physical source remains `source_product_warehouse_id`, destination stock remains `product_warehouse_id`.
- `recording_eggs`: producer kandang is the recording kandang and is stored explicitly for compatibility; physical stock remains `product_warehouse_id`, which may be a farm warehouse.
- `marketing_delivery_products`: outbound kandang attribution comes from active `stock_allocations` to `PROJECT_FLOCK_POPULATION`, `RECORDING_DEPLETION`, or `RECORDING_EGG`, with product-warehouse kandang ownership only as a fallback for historical/non-FIFO rows.
## Reporting and HPP
- Feed and OVK cost attribution should continue to follow recording-level consumption plus FIFO allocations to incoming stock.
- Egg and live-bird sales attribution should be derived from `stock_allocations` back to the originating kandang transactions or populations.
- Queries that filter or group by kandang must use explicit transaction attribution or FIFO allocation provenance, not warehouse ownership, when pooled farm stock is involved.
## Live-Data Safety
- Schema changes are additive and nullable.
- Historical rows are backfilled only when attribution is deterministic from existing rows.
- No FIFO-v2 route-rule behavior is changed unless the current code is only resyncing or constraining allocation metadata around already-created FIFO allocations.
+286
View File
@@ -0,0 +1,286 @@
# Runbook Cutover Stok Telur Historis Kandang ke Gudang Farm
## Tujuan
Runbook ini dipakai untuk memindahkan **stok telur historis yang masih on-hand di gudang kandang** ke **gudang farm** secara aman, audit-able, dan reversible.
Cutover dilakukan dengan **transfer stok eksplisit**, bukan dengan mengubah `recording_eggs.product_warehouse_id` historis.
## Scope
Runbook ini hanya untuk:
- stok telur historis kandang-level yang masih punya saldo on-hand
- lokasi yang masuk kategori **clean cutover**
- lokasi yang sudah punya gudang farm
Runbook ini **tidak** dipakai untuk:
- lokasi overlap seperti `Cijangkar`
- koreksi histori `recording_eggs`
- migrasi stok non-telur
## Kebijakan yang Dikunci
- Sumber qty yang dipindah adalah **`product_warehouses.qty` saat cutover**
- Perintah dijalankan **per lokasi**
- Wajib mulai dari `dry-run`
- `--apply` hanya boleh dijalankan setelah review dry-run dan SQL checklist
- Lokasi overlap tidak ikut otomatis kecuali ada approval khusus dan `--include-overlap`
- Rollback hanya boleh dilakukan jika transfer hasil cutover belum dipakai transaksi turunan
## Lokasi Fase 1
Lokasi yang boleh dieksekusi pada fase pertama:
- `Jamali`
- `Cantilan`
- `Darawati`
- `Tamansari`
Lokasi yang harus ditahan:
- `Cijangkar`
## Prasyarat
Sebelum eksekusi, pastikan:
- backend sudah ter-deploy dengan command [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go)
- reusable transfer core sudah ikut ter-deploy:
- [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go)
- [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go)
- migrasi farm stock attribution sebelumnya sudah terpasang
- akses database target sudah tersedia
- environment target memakai SSL bila RDS mewajibkan, contoh:
- `DB_SSLMODE=require`
## Catatan Output Command
Mode `--output table` adalah mode operasional yang direkomendasikan.
Mode `--output json` bisa dipakai, tetapi pada environment saat ini output JSON masih dapat didahului log bootstrap aplikasi atau SQL logger. Untuk review manual gunakan `table`. Untuk parsing otomatis, filter payload mulai dari `{`.
## Format Command
### Dry-run
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--output table
```
### Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--cutover-date 2026-04-07 \
--apply \
--output table
```
### Rollback Preview
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--output table
```
### Rollback Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--apply \
--output table
```
## Arti `run_id`
Setiap dry-run/apply menghasilkan `run_id`, misalnya:
```text
egg-cutover-20260407T130344.220407000Z
```
`run_id` ini wajib disimpan karena dipakai untuk:
- audit hasil cutover
- query verifikasi
- rollback
## Prosedur Eksekusi Per Lokasi
### 1. Persiapan
Tentukan:
- `location_name`
- `cutover_date`
- operator yang bertanggung jawab
Contoh:
- lokasi: `Jamali`
- cutover date: `2026-04-07`
### 2. Jalankan Dry-run
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--output table
```
Yang harus dicek pada hasil dry-run:
- status lokasi `CLEAN_CUTOVER`
- semua baris yang akan dipindah punya `status=eligible`
- gudang tujuan adalah gudang farm lokasi tersebut
- qty yang dipindah masuk akal dan sesuai saldo on-hand aktual
- tidak ada `missing_farm_warehouse`
- tidak ada `overlap_location`
### 3. Jalankan Checklist SQL Before
Gunakan file:
- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
Minimal pastikan:
- lokasi memang clean cutover
- stok telur kandang positif masih ada
- gudang farm ada
- belum ada transfer `EGG_FARM_CUTOVER` aktif untuk lokasi yang sama pada run yang akan dipakai
### 4. Simpan Evidence Sebelum Apply
Simpan:
- output dry-run
- hasil query before
- nama operator
- waktu eksekusi
Disarankan simpan dalam ticket / change record.
### 5. Jalankan Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--cutover-date 2026-04-07 \
--apply \
--output table
```
Setelah apply, simpan:
- `run_id`
- seluruh row dengan `transfer_id`
- movement number yang terbentuk
### 6. Jalankan Checklist SQL After
Masih menggunakan file:
- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
Minimal pastikan:
- transfer header/detail tercatat untuk `run_id`
- qty source berkurang sesuai transfer
- qty farm bertambah sesuai transfer
- total gabungan source+dest per produk per lokasi tetap sama
- stok eligible tidak lagi tersedia di gudang kandang
- stok telur sekarang tersedia di gudang farm
### 7. Smoke Test UI
Lakukan minimal:
- buka product stock farm untuk lokasi tersebut
- pastikan produk telur hasil migrasi muncul
- buat SO farm-level dan pastikan opsi produk telur tersedia
- pastikan recording telur baru setelah cutover tetap langsung masuk ke gudang farm
### 8. Tutup Eksekusi
Catat hasil akhir:
- sukses/gagal
- `run_id`
- lokasi
- tanggal cutover
- operator
- link ke evidence SQL/UI
## Kriteria Go / No-Go
### Boleh lanjut apply bila:
- dry-run menunjukkan hanya row yang memang expected
- lokasi `CLEAN_CUTOVER`
- gudang farm valid
- query before menunjukkan tidak ada anomaly blocking
### Wajib stop bila:
- lokasi terdeteksi `OVERLAP`
- ada qty aneh atau tidak sesuai data lapangan
- gudang farm tidak ada
- ada transfer lama serupa yang belum direkonsiliasi
- setelah apply terjadi selisih total source+dest
## Rollback Runbook
### Kapan rollback boleh dilakukan
Rollback boleh jika:
- transfer hasil cutover belum dipakai transaksi turunan
- verifikasi after menunjukkan issue yang membuat hasil cutover tidak dapat diterima
### Langkah rollback
1. Preview rollback:
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--output table
```
2. Jalankan query rollback readiness pada file audit/helper SQL.
3. Jika aman, apply rollback:
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--apply \
--output table
```
4. Jalankan ulang query verifikasi after rollback.
### Kapan rollback akan gagal by design
Rollback memang harus gagal jika:
- transfer hasil cutover sudah dipakai sales/recording/transaksi turunan
- sudah ada `stock_allocations` consume aktif terhadap `STOCK_TRANSFER_IN`
## Urutan Rollout yang Direkomendasikan
### Dev
1. Dry-run per lokasi
2. Review SQL before
3. Apply per lokasi
4. SQL after
5. Smoke UI
6. Simpan `run_id`
### Production
1. Freeze operasional lokasi target bila perlu
2. Dry-run
3. Review by dev + ops + finance/stock owner
4. Apply
5. SQL after
6. Smoke UI
7. Release lokasi berikutnya
## Referensi
- Command cutover: [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go)
- Test command: [main_test.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main_test.go)
- Core reusable transfer: [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go)
- Transfer service refactor: [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go)
- Checklist SQL: [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
- Helper query audit: [legacy_egg_cutover_audit_queries.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_audit_queries.sql)
+45
View File
@@ -0,0 +1,45 @@
ID;Kategori;Area;Judul;Tipe;Prioritas;Setup/Precondition;Langkah Uji;Hasil yang Diharapkan
TC-A01;Migrasi dan Keamanan Data;Database;Migrasi aman pada DB tidak kosong;Integration;High;Gunakan snapshot DB staging yang sudah berisi recording, depletion, telur, penjualan, dan closing.;1. Jalankan migrasi 20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql. 2. Inspect schema hasil migrasi.;Kolom recording_depletions.source_project_flock_kandang_id dan recording_eggs.project_flock_kandang_id tersedia dan nullable, index dan FK tersedia, tidak ada data historis yang terhapus atau berubah destruktif.
TC-A02;Migrasi dan Keamanan Data;Database;Backfill deterministik berjalan;Integration;High;Ada data historis recording dengan recordings.project_flock_kandangs_id yang valid.;1. Query recording_depletions dan recording_eggs yang lama. 2. Bandingkan dengan kandang pada parent recording.;source_project_flock_kandang_id dan project_flock_kandang_id terisi sama dengan kandang parent recording untuk row yang sebelumnya null.
TC-A03;Migrasi dan Keamanan Data;Reporting;Report historis kandang-only tidak berubah;Regression;High;Gunakan snapshot yang hanya memiliki data stok historis milik kandang, tanpa pooled stock farm-level.;1. Jalankan closing/report/HPP sebelum deploy. 2. Jalankan lagi sesudah deploy pada snapshot yang sama. 3. Bandingkan hasil.;Total dan hasil report tetap sama untuk skenario historis kandang-only.
TC-B01;Purchase dan Warehouse;Purchase;Purchase pakan langsung ke gudang farm;UAT;High;Tersedia PO atau purchase request untuk produk Pakan Starter.;1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase.;Stok masuk ke product_warehouse level farm, tidak perlu transfer paksa ke kandang, FIFO/HPP purchase tetap benar.
TC-B02;Purchase dan Warehouse;Purchase;Purchase pakan langsung ke gudang kandang;Regression;High;Tersedia PO atau purchase request untuk produk Pakan Starter.;1. Buat purchase ke Gudang Kandang A1. 2. Approve dan receive purchase.;Stok masuk ke gudang kandang dan perilaku tetap sama seperti flow lama.
TC-B03;Purchase dan Warehouse;Purchase;Purchase OVK langsung ke gudang farm;UAT;High;Tersedia PO atau purchase request untuk produk OVK A.;1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase.;Stok OVK masuk ke gudang farm dan bisa dipakai kemudian pada recording.
TC-B04;Purchase dan Warehouse;Product Warehouse;Gudang farm shared tidak diubah diam-diam menjadi milik kandang;Regression;High;Sudah ada row product_warehouse level farm untuk Pakan Starter di Gudang Farm A.;1. Trigger flow yang memanggil ensure/find product warehouse untuk produk yang sama. 2. Inspect row existing.;Row farm-level tetap farm-level, project_flock_kandang_id tidak dibackfill diam-diam, row khusus kandang dibuat terpisah bila memang diperlukan.
TC-C01;Recording Stock Consumption;Recording;Recording kandang memakai pakan dari gudang kandang;Regression;High;Stok pakan tersedia di Gudang Kandang A1.;1. Buka recording untuk Kandang A1. 2. Pilih pakan dari gudang kandang. 3. Submit dan approve.;Recording berhasil, stok keluar dari product_warehouse kandang, atribusi kandang tetap A1, HPP pemakaian muncul di closing/HPP A1.
TC-C02;Recording Stock Consumption;Recording;Recording kandang memakai pakan dari gudang farm;UAT;High;Stok pakan hanya tersedia di Gudang Farm A.;1. Buka recording untuk Kandang A1. 2. Pilih stok pakan farm-level. 3. Submit dan approve.;Recording berhasil tanpa transfer ke kandang, stok fisik berkurang dari gudang farm, usage/HPP tetap teratribusi ke Kandang A1, closing farm dan kandang tetap bisa dihitung.
TC-C03;Recording Stock Consumption;Recording;Recording kandang memakai OVK dari gudang farm;UAT;High;Stok OVK hanya tersedia di Gudang Farm A.;1. Buka recording untuk Kandang A1. 2. Pilih stok OVK farm-level. 3. Submit dan approve.;Stok OVK keluar dari gudang farm dan biaya pemakaian teratribusi ke kandang yang dipilih.
TC-C04;Recording Stock Consumption;Frontend Recording;Selector recording menampilkan opsi stok farm dan kandang dengan jelas;UI Regression;Medium;Produk yang sama tersedia di Gudang Farm A dan Gudang Kandang A1.;1. Buka form recording untuk A1. 2. Buka selector pakan.;Kedua opsi terlihat, label membedakan gudang atau scope dengan jelas, farm stock tidak tersembunyi secara salah.
TC-C05;Recording Stock Consumption;Recording;Recording A1 tidak boleh memakai stok kandang A2;Negative;High;Pakan Starter tersedia di Gudang Kandang A2.;1. Buka recording untuk A1. 2. Periksa opsi stok yang bisa dipilih.;Opsi Gudang Kandang A2 tidak bisa dipilih, stok farm tetap bisa dipilih.
TC-C06;Recording Stock Consumption;Recording;Perilaku pending stock dan usage lama tetap berjalan;Regression;Medium;Tidak ada setup khusus selain data recording yang valid.;1. Buat usage stock. 2. Buka kembali halaman edit dan detail.;Tampilan dan perhitungan pending atau usage tetap benar, tidak ada regresi pada route FIFO-v2.
TC-D01;Recording Telur dan Atribusi;Recording;Recording telur ke gudang kandang tetap berjalan;Regression;High;Kandang A1 aktif dan gudang telur kandang tersedia.;1. Record telur untuk A1 ke Gudang Kandang A1. 2. Approve.;Stok telur di gudang kandang bertambah dan asal kandang tetap A1.
TC-D02;Recording Telur dan Atribusi;Recording;Recording telur di kandang menyimpan stok ke gudang farm;UAT;High;Egg product warehouse tersedia di Gudang Farm A.;1. Record telur untuk A1. 2. Pilih Gudang Farm A sebagai gudang telur. 3. Submit dan approve.;Stok telur fisik masuk ke gudang farm, recording_eggs.project_flock_kandang_id bernilai A1, tidak ada transfer paksa ke kandang.
TC-D03;Recording Telur dan Atribusi;Reporting;Stok telur pooled di farm tetap punya jejak asal kandang;Integration;High;A1 record 100 telur ke gudang farm dan A2 record 150 telur ke gudang farm yang sama.;1. Inspect row telur yang tersimpan. 2. Inspect hasil costing atau report setelahnya.;Stok fisik pooled di gudang farm, tetapi asal kandang tetap bisa dibedakan per row atau allocation, HPP per kandang tetap dapat dihitung.
TC-D04;Recording Telur dan Atribusi;Recording Detail;Known gap pada detail recording dipahami;Known Limitation;Low;Sudah menjalankan TC-D02.;1. Buka detail recording setelah transaksi telur ke gudang farm.;Logika bisnis tetap berjalan, tetapi detail API atau UI mungkin belum menampilkan egg-origin secara eksplisit karena detail DTO belum diperluas.
TC-E01;Depletion dan Atribusi Populasi;Recording;Depletion dari gudang ayam milik kandang normal;Regression;High;A1 memiliki populasi ayam di gudang kandang.;1. Buat depletion. 2. Approve.;Depletion berhasil, alokasi populasi ter-resolve ke A1, HPP atau usage tetap benar.
TC-E02;Depletion dan Atribusi Populasi;Recording;Depletion dari sumber ayam fisik farm-level dengan source kandang A1;UAT;High;Stok ayam secara fisik ada di gudang farm dan punya jejak sumber ke A1.;1. Buat depletion untuk A1. 2. Gunakan path source atau farm-level yang didukung backend. 3. Approve.;source_product_warehouse_id menunjuk ke sumber fisik yang benar, source_project_flock_kandang_id bernilai A1, alokasi populasi berhasil tanpa mengasumsikan gudang fisik milik A1.
TC-E03;Depletion dan Atribusi Populasi;Recording;Depletion gagal bila sumber populasi tidak dapat diatribusikan;Negative;High;Buat kasus stok ayam farm-level tanpa source kandang yang valid.;1. Coba approve depletion.;Backend menolak dengan error yang jelas dan tidak ada silent misattribution.
TC-F01;Marketing dan Penjualan;Sales Order;Sales order dari gudang kandang tetap berjalan;Regression;High;Stok produk tersedia di Gudang Kandang A1.;1. Buat SO dari Gudang Kandang A1. 2. Lakukan delivery.;Perilaku lama tetap berjalan normal.
TC-F02;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk telur;UAT;High;Stok telur farm-level tersedia dan berasal dari A1.;1. Buat SO menggunakan Gudang Farm A. 2. Lakukan delivery.;SO dan DO berhasil, stok fisik berkurang dari gudang farm, HPP dan COGS telur tetap teratribusi ke kandang penghasil melalui allocation.
TC-F03;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk telur pooled A1 dan A2;Integration;High;Stok telur pooled tersedia di gudang farm dari A1 dan A2.;1. Buat penjualan. 2. Lakukan delivery. 3. Inspect closing atau report.;Stok fisik berkurang sekali dari gudang farm, revenue dan HPP terbagi benar ke A1 dan A2, tidak bergantung pada pw.project_flock_kandang_id.
TC-F04;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk ayam atau culling;UAT;High;Stok ayam atau culling farm-level tersedia dengan jejak sumber dari A1 dan A2.;1. Buat SO dari gudang farm. 2. Buat DO dan approve.;allocatePopulationForMarketingDelivery menurunkan atribusi kandang dari source groups atau allocation, tidak gagal karena gudang jual tidak punya project_flock_kandang_id, HPP dan COGS teratribusi ke kandang sumber.
TC-F05;Marketing dan Penjualan;Frontend Marketing;UI sales menampilkan semantik Gudang Fisik;UI Regression;Medium;Tidak ada setup khusus selain akses ke form SO.;1. Buka form SO. 2. Periksa label selector gudang dan label tabel produk.;UI menggunakan label Gudang Fisik, bukan Kandang yang menyesatkan, dan label produk memuat detail produk serta gudang atau scope.
TC-F06;Marketing dan Penjualan;Delivery Order;Layar delivery order tetap kompatibel;Regression;Medium;Sudah ada SO dari gudang farm.;1. Lakukan delivery untuk SO farm-level. 2. Periksa tabel dan detail DO.;Tidak ada masalah payload, gudang fisik tampil dengan benar, dan tidak ada kebingungan akibat wording lama berbasis kandang.
TC-G01;Report, Closing, dan HPP;Daily Marketing Report;Daily marketing report untuk penjualan telur farm-level;UAT;Medium;Sudah menjalankan TC-F02.;1. Jalankan daily marketing report. 2. Uji export.;Row muncul pada gudang fisik yang benar, report tidak menyiratkan gudang sama dengan kandang, export berjalan.
TC-G02;Report, Closing, dan HPP;Closing Sales;Closing sales untuk penjualan pooled farm-level;UAT;High;Ada penjualan pooled telur atau ayam dari gudang farm.;1. Buka closing sales.;Penjualan bisa tampil teratribusi per kandang, label menunjukkan Kandang Atribusi, HPP dan revenue tetap benar secara matematis.
TC-G03;Report, Closing, dan HPP;HPP per Kandang;HPP per kandang mencakup konsumsi pakan atau OVK dari gudang farm;UAT;High;A1 sudah memakai pakan atau OVK dari gudang farm.;1. Jalankan report HPP per kandang.;Biaya usage muncul di A1 dan tidak hilang walaupun gudang fisiknya level farm.
TC-G04;Report, Closing, dan HPP;Closing Sapronak;Outgoing sapronak menampilkan gudang fisik dengan benar;UI Regression;Medium;Ada data outgoing sapronak yang valid.;1. Buka tabel closing outgoing sapronak.;Header jelas menunjukkan Gudang Asal (Fisik) dan Gudang Tujuan (Fisik).
TC-G05;Report, Closing, dan HPP;Compatibility;Data historis kandang-owned dan pooled data baru dapat coexist;Regression;High;Dalam satu date range ada transaksi lama kandang-owned dan transaksi baru pooled farm-level.;1. Jalankan closing. 2. Jalankan report. 3. Jalankan HPP.;Kedua jenis data diproses dengan benar, tidak ada double count dan tidak ada atribusi yang hilang.
TC-H01;FIFO-v2 dan Integritas Allocation;FIFO-v2;Kontrak FIFO-v2 tidak berubah;Integration;High;Gunakan data uji yang mencakup recording stock, depletion, egg, dan marketing.;1. Verifikasi route FIFO untuk RECORDING_STOCK_OUT, RECORDING_DEPLETION_OUT, RECORDING_DEPLETION_IN, RECORDING_EGG_IN, dan MARKETING_OUT. 2. Bandingkan dengan RFC.md dan seed config FIFO-v2.;Tidak ada perubahan semantik route yang tidak disengaja.
TC-H02;FIFO-v2 dan Integritas Allocation;Stock Allocation;Stock allocation tetap konsisten untuk pakan dari gudang farm;Integration;High;Sudah menjalankan TC-C02.;1. Inspect stock_allocations setelah transaksi.;Allocation consume terbentuk dengan benar dan tidak ada row allocation yatim atau rusak.
TC-H03;FIFO-v2 dan Integritas Allocation;Stock Allocation;Stock allocation tetap konsisten untuk penjualan telur pooled;Integration;High;Sudah menjalankan TC-F03.;1. Inspect stock_allocations. 2. Inspect row atribusi turunannya.;Allocation mendukung atribusi HPP kembali ke kandang sumber.
TC-H04;FIFO-v2 dan Integritas Allocation;Population Allocation;Population allocation tetap konsisten untuk penjualan ayam pooled;Integration;High;Sudah menjalankan TC-F04.;1. Inspect population allocations.;Penggunaan kandang sumber teralokasi dengan benar dan tidak fallback ke atribusi null saat source tersedia.
TC-I01;Negative dan Guard Cases;Recording;Recording dari stok farm-level dengan qty tidak cukup;Negative;High;Stok farm-level tersedia tetapi qty lebih kecil dari pemakaian yang diinput.;1. Buat recording dengan qty melebihi stok. 2. Submit atau approve.;Muncul validation atau business error dan tidak ada korupsi parsial.
TC-I02;Negative dan Guard Cases;Marketing;Marketing dari stok farm-level dengan qty tidak cukup;Negative;High;Stok farm-level tersedia tetapi qty lebih kecil dari qty penjualan.;1. Buat SO atau DO dengan qty melebihi stok. 2. Submit atau approve.;Delivery atau approval diblok dan stok tetap konsisten.
TC-I03;Negative dan Guard Cases;Frontend Selector;Opsi produk sama di gudang berbeda tidak salah terpilih;UI Regression;Medium;Produk yang sama tersedia di gudang farm dan gudang kandang.;1. Pilih masing-masing opsi secara eksplisit di UI. 2. Save. 3. Buka kembali edit atau detail.;Opsi yang terpilih jelas dan tetap stabil setelah save atau edit.
TC-I04;Negative dan Guard Cases;Product Warehouse;Row gudang shared tidak diatribusikan ulang oleh flow maintenance;Regression;High;Ada row shared farm warehouse yang sudah aktif.;1. Jalankan flow yang menyentuh logic ensure/find product warehouse. 2. Cek ulang row farm shared.;Tidak ada mutasi diam-diam pada project_flock_kandang_id.
TC-J01;Regression Frontend dan UX;Recording Form;Form recording menampilkan opsi stok farm dan kandang hanya dalam scope farm yang sama;UI Regression;Medium;Ada stok di gudang farm, gudang kandang saat ini, dan gudang kandang lain.;1. Buka form recording untuk kandang tertentu. 2. Periksa opsi stock selector.;Gudang farm dan gudang kandang saat ini terlihat, gudang kandang lain tersembunyi.
TC-J02;Regression Frontend dan UX;Recording Form;Selector recording telur mengizinkan gudang farm;UI Regression;Medium;Egg warehouse tersedia di gudang farm.;1. Buka form recording telur. 2. Buka selector tujuan telur.;Gudang farm terlihat sebagai opsi tujuan telur.
TC-J03;Regression Frontend dan UX;Sales Form;Form sales memakai semantik gudang secara konsisten;UI Regression;Medium;Akses ke halaman marketing tersedia.;1. Buka form sales. 2. Periksa label selector dan summary table.;Label menggunakan Gudang Fisik secara konsisten dan tidak ada wording Kandang yang menyesatkan untuk stok fisik.
TC-J04;Regression Frontend dan UX;Marketing Modal;Modal list marketing menampilkan label gudang fisik;UI Regression;Low;Akses ke modal product list tersedia.;1. Buka modal product list di marketing.;Kolom menampilkan label Gudang Fisik.
TC-K01;Known Limitation;Recording Detail;Detail recording belum menampilkan source atau origin attribution baru;Known Limitation;Low;Sudah ada recording telur farm-level dan depletion dengan source attribution.;1. Buat transaksi. 2. Buka detail recording.;Transaksi berjalan dan atribusi tersimpan di DB, tetapi detail API atau UI mungkin belum menampilkan field source atau origin tersebut
1 ID Kategori Area Judul Tipe Prioritas Setup/Precondition Langkah Uji Hasil yang Diharapkan
2 TC-A01 Migrasi dan Keamanan Data Database Migrasi aman pada DB tidak kosong Integration High Gunakan snapshot DB staging yang sudah berisi recording, depletion, telur, penjualan, dan closing. 1. Jalankan migrasi 20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql. 2. Inspect schema hasil migrasi. Kolom recording_depletions.source_project_flock_kandang_id dan recording_eggs.project_flock_kandang_id tersedia dan nullable, index dan FK tersedia, tidak ada data historis yang terhapus atau berubah destruktif.
3 TC-A02 Migrasi dan Keamanan Data Database Backfill deterministik berjalan Integration High Ada data historis recording dengan recordings.project_flock_kandangs_id yang valid. 1. Query recording_depletions dan recording_eggs yang lama. 2. Bandingkan dengan kandang pada parent recording. source_project_flock_kandang_id dan project_flock_kandang_id terisi sama dengan kandang parent recording untuk row yang sebelumnya null.
4 TC-A03 Migrasi dan Keamanan Data Reporting Report historis kandang-only tidak berubah Regression High Gunakan snapshot yang hanya memiliki data stok historis milik kandang, tanpa pooled stock farm-level. 1. Jalankan closing/report/HPP sebelum deploy. 2. Jalankan lagi sesudah deploy pada snapshot yang sama. 3. Bandingkan hasil. Total dan hasil report tetap sama untuk skenario historis kandang-only.
5 TC-B01 Purchase dan Warehouse Purchase Purchase pakan langsung ke gudang farm UAT High Tersedia PO atau purchase request untuk produk Pakan Starter. 1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase. Stok masuk ke product_warehouse level farm, tidak perlu transfer paksa ke kandang, FIFO/HPP purchase tetap benar.
6 TC-B02 Purchase dan Warehouse Purchase Purchase pakan langsung ke gudang kandang Regression High Tersedia PO atau purchase request untuk produk Pakan Starter. 1. Buat purchase ke Gudang Kandang A1. 2. Approve dan receive purchase. Stok masuk ke gudang kandang dan perilaku tetap sama seperti flow lama.
7 TC-B03 Purchase dan Warehouse Purchase Purchase OVK langsung ke gudang farm UAT High Tersedia PO atau purchase request untuk produk OVK A. 1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase. Stok OVK masuk ke gudang farm dan bisa dipakai kemudian pada recording.
8 TC-B04 Purchase dan Warehouse Product Warehouse Gudang farm shared tidak diubah diam-diam menjadi milik kandang Regression High Sudah ada row product_warehouse level farm untuk Pakan Starter di Gudang Farm A. 1. Trigger flow yang memanggil ensure/find product warehouse untuk produk yang sama. 2. Inspect row existing. Row farm-level tetap farm-level, project_flock_kandang_id tidak dibackfill diam-diam, row khusus kandang dibuat terpisah bila memang diperlukan.
9 TC-C01 Recording Stock Consumption Recording Recording kandang memakai pakan dari gudang kandang Regression High Stok pakan tersedia di Gudang Kandang A1. 1. Buka recording untuk Kandang A1. 2. Pilih pakan dari gudang kandang. 3. Submit dan approve. Recording berhasil, stok keluar dari product_warehouse kandang, atribusi kandang tetap A1, HPP pemakaian muncul di closing/HPP A1.
10 TC-C02 Recording Stock Consumption Recording Recording kandang memakai pakan dari gudang farm UAT High Stok pakan hanya tersedia di Gudang Farm A. 1. Buka recording untuk Kandang A1. 2. Pilih stok pakan farm-level. 3. Submit dan approve. Recording berhasil tanpa transfer ke kandang, stok fisik berkurang dari gudang farm, usage/HPP tetap teratribusi ke Kandang A1, closing farm dan kandang tetap bisa dihitung.
11 TC-C03 Recording Stock Consumption Recording Recording kandang memakai OVK dari gudang farm UAT High Stok OVK hanya tersedia di Gudang Farm A. 1. Buka recording untuk Kandang A1. 2. Pilih stok OVK farm-level. 3. Submit dan approve. Stok OVK keluar dari gudang farm dan biaya pemakaian teratribusi ke kandang yang dipilih.
12 TC-C04 Recording Stock Consumption Frontend Recording Selector recording menampilkan opsi stok farm dan kandang dengan jelas UI Regression Medium Produk yang sama tersedia di Gudang Farm A dan Gudang Kandang A1. 1. Buka form recording untuk A1. 2. Buka selector pakan. Kedua opsi terlihat, label membedakan gudang atau scope dengan jelas, farm stock tidak tersembunyi secara salah.
13 TC-C05 Recording Stock Consumption Recording Recording A1 tidak boleh memakai stok kandang A2 Negative High Pakan Starter tersedia di Gudang Kandang A2. 1. Buka recording untuk A1. 2. Periksa opsi stok yang bisa dipilih. Opsi Gudang Kandang A2 tidak bisa dipilih, stok farm tetap bisa dipilih.
14 TC-C06 Recording Stock Consumption Recording Perilaku pending stock dan usage lama tetap berjalan Regression Medium Tidak ada setup khusus selain data recording yang valid. 1. Buat usage stock. 2. Buka kembali halaman edit dan detail. Tampilan dan perhitungan pending atau usage tetap benar, tidak ada regresi pada route FIFO-v2.
15 TC-D01 Recording Telur dan Atribusi Recording Recording telur ke gudang kandang tetap berjalan Regression High Kandang A1 aktif dan gudang telur kandang tersedia. 1. Record telur untuk A1 ke Gudang Kandang A1. 2. Approve. Stok telur di gudang kandang bertambah dan asal kandang tetap A1.
16 TC-D02 Recording Telur dan Atribusi Recording Recording telur di kandang menyimpan stok ke gudang farm UAT High Egg product warehouse tersedia di Gudang Farm A. 1. Record telur untuk A1. 2. Pilih Gudang Farm A sebagai gudang telur. 3. Submit dan approve. Stok telur fisik masuk ke gudang farm, recording_eggs.project_flock_kandang_id bernilai A1, tidak ada transfer paksa ke kandang.
17 TC-D03 Recording Telur dan Atribusi Reporting Stok telur pooled di farm tetap punya jejak asal kandang Integration High A1 record 100 telur ke gudang farm dan A2 record 150 telur ke gudang farm yang sama. 1. Inspect row telur yang tersimpan. 2. Inspect hasil costing atau report setelahnya. Stok fisik pooled di gudang farm, tetapi asal kandang tetap bisa dibedakan per row atau allocation, HPP per kandang tetap dapat dihitung.
18 TC-D04 Recording Telur dan Atribusi Recording Detail Known gap pada detail recording dipahami Known Limitation Low Sudah menjalankan TC-D02. 1. Buka detail recording setelah transaksi telur ke gudang farm. Logika bisnis tetap berjalan, tetapi detail API atau UI mungkin belum menampilkan egg-origin secara eksplisit karena detail DTO belum diperluas.
19 TC-E01 Depletion dan Atribusi Populasi Recording Depletion dari gudang ayam milik kandang normal Regression High A1 memiliki populasi ayam di gudang kandang. 1. Buat depletion. 2. Approve. Depletion berhasil, alokasi populasi ter-resolve ke A1, HPP atau usage tetap benar.
20 TC-E02 Depletion dan Atribusi Populasi Recording Depletion dari sumber ayam fisik farm-level dengan source kandang A1 UAT High Stok ayam secara fisik ada di gudang farm dan punya jejak sumber ke A1. 1. Buat depletion untuk A1. 2. Gunakan path source atau farm-level yang didukung backend. 3. Approve. source_product_warehouse_id menunjuk ke sumber fisik yang benar, source_project_flock_kandang_id bernilai A1, alokasi populasi berhasil tanpa mengasumsikan gudang fisik milik A1.
21 TC-E03 Depletion dan Atribusi Populasi Recording Depletion gagal bila sumber populasi tidak dapat diatribusikan Negative High Buat kasus stok ayam farm-level tanpa source kandang yang valid. 1. Coba approve depletion. Backend menolak dengan error yang jelas dan tidak ada silent misattribution.
22 TC-F01 Marketing dan Penjualan Sales Order Sales order dari gudang kandang tetap berjalan Regression High Stok produk tersedia di Gudang Kandang A1. 1. Buat SO dari Gudang Kandang A1. 2. Lakukan delivery. Perilaku lama tetap berjalan normal.
23 TC-F02 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk telur UAT High Stok telur farm-level tersedia dan berasal dari A1. 1. Buat SO menggunakan Gudang Farm A. 2. Lakukan delivery. SO dan DO berhasil, stok fisik berkurang dari gudang farm, HPP dan COGS telur tetap teratribusi ke kandang penghasil melalui allocation.
24 TC-F03 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk telur pooled A1 dan A2 Integration High Stok telur pooled tersedia di gudang farm dari A1 dan A2. 1. Buat penjualan. 2. Lakukan delivery. 3. Inspect closing atau report. Stok fisik berkurang sekali dari gudang farm, revenue dan HPP terbagi benar ke A1 dan A2, tidak bergantung pada pw.project_flock_kandang_id.
25 TC-F04 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk ayam atau culling UAT High Stok ayam atau culling farm-level tersedia dengan jejak sumber dari A1 dan A2. 1. Buat SO dari gudang farm. 2. Buat DO dan approve. allocatePopulationForMarketingDelivery menurunkan atribusi kandang dari source groups atau allocation, tidak gagal karena gudang jual tidak punya project_flock_kandang_id, HPP dan COGS teratribusi ke kandang sumber.
26 TC-F05 Marketing dan Penjualan Frontend Marketing UI sales menampilkan semantik Gudang Fisik UI Regression Medium Tidak ada setup khusus selain akses ke form SO. 1. Buka form SO. 2. Periksa label selector gudang dan label tabel produk. UI menggunakan label Gudang Fisik, bukan Kandang yang menyesatkan, dan label produk memuat detail produk serta gudang atau scope.
27 TC-F06 Marketing dan Penjualan Delivery Order Layar delivery order tetap kompatibel Regression Medium Sudah ada SO dari gudang farm. 1. Lakukan delivery untuk SO farm-level. 2. Periksa tabel dan detail DO. Tidak ada masalah payload, gudang fisik tampil dengan benar, dan tidak ada kebingungan akibat wording lama berbasis kandang.
28 TC-G01 Report, Closing, dan HPP Daily Marketing Report Daily marketing report untuk penjualan telur farm-level UAT Medium Sudah menjalankan TC-F02. 1. Jalankan daily marketing report. 2. Uji export. Row muncul pada gudang fisik yang benar, report tidak menyiratkan gudang sama dengan kandang, export berjalan.
29 TC-G02 Report, Closing, dan HPP Closing Sales Closing sales untuk penjualan pooled farm-level UAT High Ada penjualan pooled telur atau ayam dari gudang farm. 1. Buka closing sales. Penjualan bisa tampil teratribusi per kandang, label menunjukkan Kandang Atribusi, HPP dan revenue tetap benar secara matematis.
30 TC-G03 Report, Closing, dan HPP HPP per Kandang HPP per kandang mencakup konsumsi pakan atau OVK dari gudang farm UAT High A1 sudah memakai pakan atau OVK dari gudang farm. 1. Jalankan report HPP per kandang. Biaya usage muncul di A1 dan tidak hilang walaupun gudang fisiknya level farm.
31 TC-G04 Report, Closing, dan HPP Closing Sapronak Outgoing sapronak menampilkan gudang fisik dengan benar UI Regression Medium Ada data outgoing sapronak yang valid. 1. Buka tabel closing outgoing sapronak. Header jelas menunjukkan Gudang Asal (Fisik) dan Gudang Tujuan (Fisik).
32 TC-G05 Report, Closing, dan HPP Compatibility Data historis kandang-owned dan pooled data baru dapat coexist Regression High Dalam satu date range ada transaksi lama kandang-owned dan transaksi baru pooled farm-level. 1. Jalankan closing. 2. Jalankan report. 3. Jalankan HPP. Kedua jenis data diproses dengan benar, tidak ada double count dan tidak ada atribusi yang hilang.
33 TC-H01 FIFO-v2 dan Integritas Allocation FIFO-v2 Kontrak FIFO-v2 tidak berubah Integration High Gunakan data uji yang mencakup recording stock, depletion, egg, dan marketing. 1. Verifikasi route FIFO untuk RECORDING_STOCK_OUT, RECORDING_DEPLETION_OUT, RECORDING_DEPLETION_IN, RECORDING_EGG_IN, dan MARKETING_OUT. 2. Bandingkan dengan RFC.md dan seed config FIFO-v2. Tidak ada perubahan semantik route yang tidak disengaja.
34 TC-H02 FIFO-v2 dan Integritas Allocation Stock Allocation Stock allocation tetap konsisten untuk pakan dari gudang farm Integration High Sudah menjalankan TC-C02. 1. Inspect stock_allocations setelah transaksi. Allocation consume terbentuk dengan benar dan tidak ada row allocation yatim atau rusak.
35 TC-H03 FIFO-v2 dan Integritas Allocation Stock Allocation Stock allocation tetap konsisten untuk penjualan telur pooled Integration High Sudah menjalankan TC-F03. 1. Inspect stock_allocations. 2. Inspect row atribusi turunannya. Allocation mendukung atribusi HPP kembali ke kandang sumber.
36 TC-H04 FIFO-v2 dan Integritas Allocation Population Allocation Population allocation tetap konsisten untuk penjualan ayam pooled Integration High Sudah menjalankan TC-F04. 1. Inspect population allocations. Penggunaan kandang sumber teralokasi dengan benar dan tidak fallback ke atribusi null saat source tersedia.
37 TC-I01 Negative dan Guard Cases Recording Recording dari stok farm-level dengan qty tidak cukup Negative High Stok farm-level tersedia tetapi qty lebih kecil dari pemakaian yang diinput. 1. Buat recording dengan qty melebihi stok. 2. Submit atau approve. Muncul validation atau business error dan tidak ada korupsi parsial.
38 TC-I02 Negative dan Guard Cases Marketing Marketing dari stok farm-level dengan qty tidak cukup Negative High Stok farm-level tersedia tetapi qty lebih kecil dari qty penjualan. 1. Buat SO atau DO dengan qty melebihi stok. 2. Submit atau approve. Delivery atau approval diblok dan stok tetap konsisten.
39 TC-I03 Negative dan Guard Cases Frontend Selector Opsi produk sama di gudang berbeda tidak salah terpilih UI Regression Medium Produk yang sama tersedia di gudang farm dan gudang kandang. 1. Pilih masing-masing opsi secara eksplisit di UI. 2. Save. 3. Buka kembali edit atau detail. Opsi yang terpilih jelas dan tetap stabil setelah save atau edit.
40 TC-I04 Negative dan Guard Cases Product Warehouse Row gudang shared tidak diatribusikan ulang oleh flow maintenance Regression High Ada row shared farm warehouse yang sudah aktif. 1. Jalankan flow yang menyentuh logic ensure/find product warehouse. 2. Cek ulang row farm shared. Tidak ada mutasi diam-diam pada project_flock_kandang_id.
41 TC-J01 Regression Frontend dan UX Recording Form Form recording menampilkan opsi stok farm dan kandang hanya dalam scope farm yang sama UI Regression Medium Ada stok di gudang farm, gudang kandang saat ini, dan gudang kandang lain. 1. Buka form recording untuk kandang tertentu. 2. Periksa opsi stock selector. Gudang farm dan gudang kandang saat ini terlihat, gudang kandang lain tersembunyi.
42 TC-J02 Regression Frontend dan UX Recording Form Selector recording telur mengizinkan gudang farm UI Regression Medium Egg warehouse tersedia di gudang farm. 1. Buka form recording telur. 2. Buka selector tujuan telur. Gudang farm terlihat sebagai opsi tujuan telur.
43 TC-J03 Regression Frontend dan UX Sales Form Form sales memakai semantik gudang secara konsisten UI Regression Medium Akses ke halaman marketing tersedia. 1. Buka form sales. 2. Periksa label selector dan summary table. Label menggunakan Gudang Fisik secara konsisten dan tidak ada wording Kandang yang menyesatkan untuk stok fisik.
44 TC-J04 Regression Frontend dan UX Marketing Modal Modal list marketing menampilkan label gudang fisik UI Regression Low Akses ke modal product list tersedia. 1. Buka modal product list di marketing. Kolom menampilkan label Gudang Fisik.
45 TC-K01 Known Limitation Recording Detail Detail recording belum menampilkan source atau origin attribution baru Known Limitation Low Sudah ada recording telur farm-level dan depletion dengan source attribution. 1. Buat transaksi. 2. Buka detail recording. Transaksi berjalan dan atribusi tersimpan di DB, tetapi detail API atau UI mungkin belum menampilkan field source atau origin tersebut
Binary file not shown.
@@ -0,0 +1,343 @@
-- Legacy Egg Cutover Audit Helper Queries
-- Ad-hoc query pack for investigation, audit, dry-run review, and rollback readiness.
-- =====================================================================
-- AUDIT-01 All locations classified by kandang/farm egg posting timing
-- =====================================================================
WITH timing AS (
SELECT
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pk.project_flock_id
JOIN locations l ON l.id = pf.location_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
GROUP BY pf.location_id, l.name
)
SELECT
location_id,
location_name,
first_kandang_date,
last_kandang_date,
first_farm_date,
last_farm_date,
CASE
WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY'
WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER'
ELSE 'OVERLAP'
END AS location_status
FROM timing
ORDER BY location_name;
-- =====================================================================
-- AUDIT-02 All legacy kandang egg product warehouses with positive on-hand
-- =====================================================================
WITH first_farm AS (
SELECT location_id, MIN(id) AS farm_warehouse_id
FROM warehouses
WHERE type = 'LOKASI'
AND deleted_at IS NULL
GROUP BY location_id
)
SELECT
l.id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
p.id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
LEFT JOIN first_farm ff ON ff.location_id = kw.location_id
LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id
WHERE EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
ORDER BY l.name, kw.name, p.name;
-- =====================================================================
-- AUDIT-03 Totals per location for phase sizing
-- =====================================================================
WITH candidates AS (
SELECT
l.name AS location_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
)
SELECT
location_name,
COUNT(*) AS positive_rows,
SUM(on_hand_qty) AS total_on_hand_qty
FROM candidates
GROUP BY location_name
ORDER BY location_name;
-- =====================================================================
-- AUDIT-04 Locations missing farm warehouse
-- =====================================================================
SELECT
l.id AS location_id,
l.name AS location_name
FROM locations l
WHERE EXISTS (
SELECT 1
FROM warehouses kw
WHERE kw.location_id = l.id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1
FROM warehouses fw
WHERE fw.location_id = l.id
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
)
ORDER BY l.name;
-- =====================================================================
-- AUDIT-05 Legacy recording_eggs still pointing to kandang warehouse
-- =====================================================================
SELECT
l.name AS location_name,
kw.name AS kandang_warehouse_name,
p.name AS product_name,
COUNT(*) AS recording_rows
FROM recording_eggs re
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE kw.type = 'KANDANG'
GROUP BY l.name, kw.name, p.name
ORDER BY l.name, kw.name, p.name;
-- =====================================================================
-- AUDIT-06 Farm-level recording_eggs already present
-- =====================================================================
SELECT
l.name AS location_name,
fw.name AS farm_warehouse_name,
p.name AS product_name,
COUNT(*) AS recording_rows
FROM recording_eggs re
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE fw.type = 'LOKASI'
GROUP BY l.name, fw.name, p.name
ORDER BY l.name, fw.name, p.name;
-- =====================================================================
-- AUDIT-07 Transfers created by cutover reason, grouped by run_id
-- =====================================================================
SELECT
SPLIT_PART(SPLIT_PART(st.reason, '|run_id=', 2), '|', 1) AS run_id,
COUNT(DISTINCT st.id) AS transfer_count,
COUNT(std.id) AS detail_count,
SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty,
MIN(st.transfer_date) AS first_transfer_date,
MAX(st.transfer_date) AS last_transfer_date
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=%'
GROUP BY 1
ORDER BY first_transfer_date DESC, run_id DESC;
-- =====================================================================
-- AUDIT-08 Detailed summary per run_id
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
st.transfer_date,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
st.deleted_at
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name;
-- =====================================================================
-- AUDIT-09 Downstream consumption check per run_id
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sa.usable_type,
sa.usable_id,
sa.qty,
sa.function_code,
sa.flag_group_code
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_allocations sa
ON sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.deleted_at IS NULL
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sa.usable_type, sa.usable_id;
-- =====================================================================
-- AUDIT-10 Stock log reconciliation per cutover transfer detail
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
std.id AS transfer_detail_id,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END) AS total_logged_out,
SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END) AS total_logged_in
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
LEFT JOIN stock_logs sl
ON sl.loggable_type = 'TRANSFER'
AND sl.loggable_id = std.id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
GROUP BY st.id, st.movement_number, p.name, std.id, COALESCE(std.total_qty, std.usage_qty, 0)
ORDER BY st.id, p.name;
-- =====================================================================
-- AUDIT-11 New recording eggs still posting to kandang after cutoff date
-- Replace values before running.
-- =====================================================================
SELECT
DATE(r.record_datetime) AS record_date,
l.name AS location_name,
kw.name AS kandang_warehouse_name,
p.name AS product_name,
re.qty
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE kw.type = 'KANDANG'
AND LOWER(l.name) = LOWER('<location_name>')
AND DATE(r.record_datetime) >= DATE('<cutover_date>')
ORDER BY r.record_datetime ASC, kw.name, p.name;
-- Expectation:
-- - after deploy and cutover, this should ideally return 0 rows for the location
-- =====================================================================
-- AUDIT-12 Combined kandang + farm egg stock per location after cutover
-- Replace <location_name> before running.
-- =====================================================================
SELECT
l.name AS location_name,
w.type AS warehouse_type,
p.name AS product_name,
SUM(COALESCE(pw.qty, 0)) AS total_qty
FROM product_warehouses pw
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN locations l ON l.id = w.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
GROUP BY l.name, w.type, p.name
ORDER BY w.type, p.name;
@@ -0,0 +1,400 @@
-- Legacy Egg Cutover Verification Checklist
-- Usage:
-- 1. Replace the values below before executing.
-- 2. Run section BEFORE before --apply.
-- 3. Run section AFTER after --apply.
-- 4. Run rollback checks if needed.
-- =====================================================================
-- PARAMETERS
-- =====================================================================
-- Replace manually before running.
-- Example:
-- location_name = Jamali
-- cutover_date = 2026-04-07
-- run_id = egg-cutover-20260407T130344.220407000Z
-- =====================================================================
-- BEFORE APPLY
-- =====================================================================
-- [BEFORE-01] Identify target location and farm warehouse
SELECT
l.id AS location_id,
l.name AS location_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name
FROM locations l
LEFT JOIN warehouses fw
ON fw.location_id = l.id
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
WHERE LOWER(l.name) = LOWER('<location_name>')
ORDER BY fw.id ASC;
-- Expectation:
-- - exactly one target location
-- - at least one farm warehouse exists
-- [BEFORE-02] Verify location timing status (must be CLEAN_CUTOVER for phase 1)
WITH timing AS (
SELECT
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pk.project_flock_id
JOIN locations l ON l.id = pf.location_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE LOWER(l.name) = LOWER('<location_name>')
GROUP BY pf.location_id, l.name
)
SELECT
location_id,
location_name,
first_kandang_date,
last_kandang_date,
first_farm_date,
last_farm_date,
CASE
WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY'
WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER'
ELSE 'OVERLAP'
END AS location_status
FROM timing;
-- Expectation:
-- - phase 1 location must be CLEAN_CUTOVER
-- [BEFORE-03] Candidate source rows that should be migrated
WITH first_farm AS (
SELECT location_id, MIN(id) AS farm_warehouse_id
FROM warehouses
WHERE type = 'LOKASI'
AND deleted_at IS NULL
GROUP BY location_id
)
SELECT
l.id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
p.id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
LEFT JOIN first_farm ff ON ff.location_id = kw.location_id
LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND EXISTS (
SELECT 1
FROM recording_eggs re
WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
ORDER BY kw.name, p.name;
-- Expectation:
-- - every row here should match dry-run eligible rows
-- [BEFORE-04] Totals per source warehouse and product
WITH candidates AS (
SELECT
kw.name AS source_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
)
SELECT
source_warehouse_name,
product_name,
SUM(on_hand_qty) AS total_qty
FROM candidates
GROUP BY source_warehouse_name, product_name
ORDER BY source_warehouse_name, product_name;
-- [BEFORE-05] Current farm egg stock before cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS farm_on_hand_qty
FROM warehouses fw
JOIN locations l ON l.id = fw.location_id
JOIN product_warehouses pw ON pw.warehouse_id = fw.id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
ORDER BY p.name;
-- [BEFORE-06] Existing cutover transfers for this location
SELECT
st.id,
st.movement_number,
st.transfer_date,
st.reason,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
st.deleted_at
FROM stock_transfers st
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
LEFT JOIN locations l ON l.id = COALESCE(ws.location_id, wd.location_id)
WHERE LOWER(COALESCE(l.name, '')) = LOWER('<location_name>')
AND st.reason LIKE 'EGG_FARM_CUTOVER|%'
ORDER BY st.id DESC;
-- Expectation:
-- - no unexpected older active cutover transfers for the same location
-- =====================================================================
-- AFTER APPLY
-- =====================================================================
-- [AFTER-01] Transfer headers created by run_id
SELECT
st.id,
st.movement_number,
st.transfer_date,
st.reason,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
st.deleted_at
FROM stock_transfers st
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id ASC;
-- [AFTER-02] Transfer detail rows created by run_id
SELECT
st.id AS transfer_id,
st.movement_number,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
std.source_product_warehouse_id,
std.dest_product_warehouse_id
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name;
-- [AFTER-03] Stock logs created by run_id transfer details
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sl.product_warehouse_id,
sl.increase,
sl.decrease,
sl.stock,
sl.created_at
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_logs sl
ON sl.loggable_type = 'TRANSFER'
AND sl.loggable_id = std.id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sl.id;
-- Expectation:
-- - every detail has one stock log decrease from source and one stock log increase to destination
-- [AFTER-04] Source rows after cutover
SELECT
kw.name AS source_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS source_qty_after
FROM product_warehouses pw
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND kw.type = 'KANDANG'
AND EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
ORDER BY kw.name, p.name;
-- Expectation:
-- - rows that were transferred should now be 0 or no longer available for use
-- [AFTER-05] Farm rows after cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS farm_qty_after
FROM product_warehouses pw
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
ORDER BY fw.name, p.name;
-- Expectation:
-- - farm qty increases by the moved amount
-- [AFTER-06] Reconciliation: total moved by run
SELECT
p.name AS product_name,
SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
GROUP BY p.name
ORDER BY p.name;
-- [AFTER-07] Farm stock available for SO after cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS available_qty
FROM product_warehouses pw
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
AND COALESCE(pw.qty, 0) > 0
ORDER BY p.name;
-- =====================================================================
-- ROLLBACK CHECKS
-- =====================================================================
-- [ROLLBACK-01] Check downstream consumption guard before rollback
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sa.usable_type,
sa.usable_id,
sa.qty,
sa.function_code,
sa.flag_group_code
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_allocations sa
ON sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.deleted_at IS NULL
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sa.usable_type, sa.usable_id;
-- Expectation:
-- - rollback only safe if this query returns 0 rows
-- [ROLLBACK-02] Verify run is fully rolled back
SELECT
st.id,
st.movement_number,
st.deleted_at
FROM stock_transfers st
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id;
-- Expectation:
-- - after rollback, deleted_at should be filled for all transfers in the run
@@ -0,0 +1,224 @@
package repository
import (
"fmt"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type MarketingDeliveryAttributionRow struct {
MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"`
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProjectFlockID uint `gorm:"column:project_flock_id"`
ProjectFlockCategory string `gorm:"column:project_flock_category"`
AllocatedQty float64 `gorm:"column:allocated_qty"`
}
func MarketingDeliveryAttributionRowsQuery(db *gorm.DB) *gorm.DB {
sql := `
WITH mapped AS (
SELECT
sa.usable_id AS marketing_delivery_product_id,
pc.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN project_flock_populations pfp
ON pfp.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, pc.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN recording_eggs re
ON re.id = sa.stockable_id
AND sa.stockable_type = ?
LEFT JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN recording_depletions rd
ON rd.id = sa.stockable_id
AND sa.stockable_type = ?
LEFT JOIN recordings r ON r.id = rd.recording_id
JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
pi.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN purchase_items pi
ON pi.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_flock_kandangs pfk ON pfk.id = pi.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND pi.project_flock_kandang_id IS NOT NULL
GROUP BY sa.usable_id, pi.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
source_pw.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN stock_transfer_details std
ON std.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id
JOIN project_flock_kandangs pfk ON pfk.id = source_pw.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND source_pw.project_flock_kandang_id IS NOT NULL
GROUP BY sa.usable_id, source_pw.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
ltt.target_project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN laying_transfer_targets ltt
ON ltt.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_flock_kandangs pfk ON pfk.id = ltt.target_project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, ltt.target_project_flock_kandang_id, pfk.project_flock_id, pf.category
)
SELECT
src.marketing_delivery_product_id,
src.project_flock_kandang_id,
src.project_flock_id,
src.project_flock_category,
SUM(src.allocated_qty) AS allocated_qty
FROM (
SELECT
mapped.marketing_delivery_product_id,
mapped.project_flock_kandang_id,
mapped.project_flock_id,
mapped.project_flock_category,
mapped.allocated_qty
FROM mapped
UNION ALL
SELECT
mdp.id AS marketing_delivery_product_id,
pw.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
COALESCE(mdp.usage_qty, 0) AS allocated_qty
FROM marketing_delivery_products mdp
JOIN marketing_products mp ON mp.id = mdp.marketing_product_id
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
LEFT JOIN mapped ON mapped.marketing_delivery_product_id = mdp.id
WHERE mapped.marketing_delivery_product_id IS NULL
AND pw.project_flock_kandang_id IS NOT NULL
AND COALESCE(mdp.usage_qty, 0) > 0
) src
GROUP BY
src.marketing_delivery_product_id,
src.project_flock_kandang_id,
src.project_flock_id,
src.project_flock_category
`
return db.Raw(
sql,
fifo.StockableKeyProjectFlockPopulation.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyRecordingDepletion.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyStockTransferIn.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyTransferToLayingIn.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
)
}
func MarketingDeliverySingleAttributionQuery(db *gorm.DB) *gorm.DB {
return db.
Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)).
Select(`
mda.marketing_delivery_product_id,
CASE
WHEN COUNT(DISTINCT mda.project_flock_kandang_id) = 1 THEN MIN(mda.project_flock_kandang_id)
ELSE NULL
END AS attributed_project_flock_kandang_id
`).
Group("mda.marketing_delivery_product_id")
}
func MarketingDeliveryAttributionFilterSQL(column string) string {
return fmt.Sprintf("EXISTS (SELECT 1 FROM (?) AS mda WHERE mda.marketing_delivery_product_id = %s)", column)
}
@@ -0,0 +1,147 @@
package repository
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestMarketingDeliveryAttributionRowsQueryIncludesMappedAndFallbackRows(t *testing.T) {
db := setupMarketingAttributionTestDB(t)
statements := []string{
`INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`,
`INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`,
`INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`,
`INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`,
`INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`,
`INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`,
`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES
(1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'),
(2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'),
(3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding fixtures: %v", err)
}
}
var rows []MarketingDeliveryAttributionRow
if err := db.Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)).
Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC").
Scan(&rows).Error; err != nil {
t.Fatalf("failed scanning attribution rows: %v", err)
}
if len(rows) != 4 {
t.Fatalf("expected 4 attribution rows, got %d", len(rows))
}
if rows[0].MarketingDeliveryProductID != 601 || rows[0].ProjectFlockKandangID != 101 || rows[0].AllocatedQty != 60 {
t.Fatalf("unexpected first attribution row: %+v", rows[0])
}
if rows[1].MarketingDeliveryProductID != 601 || rows[1].ProjectFlockKandangID != 102 || rows[1].AllocatedQty != 40 {
t.Fatalf("unexpected second attribution row: %+v", rows[1])
}
if rows[2].MarketingDeliveryProductID != 602 || rows[2].ProjectFlockKandangID != 101 || rows[2].AllocatedQty != 25 {
t.Fatalf("unexpected fallback attribution row: %+v", rows[2])
}
if rows[3].MarketingDeliveryProductID != 603 || rows[3].ProjectFlockKandangID != 101 || rows[3].AllocatedQty != 12 {
t.Fatalf("unexpected egg attribution row: %+v", rows[3])
}
}
func TestMarketingDeliverySingleAttributionQueryOnlyReturnsSingleSourceRows(t *testing.T) {
db := setupMarketingAttributionTestDB(t)
statements := []string{
`INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`,
`INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`,
`INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`,
`INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`,
`INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`,
`INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`,
`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES
(1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'),
(2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'),
(3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding fixtures: %v", err)
}
}
type singleRow struct {
MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"`
AttributedProjectFlockKandangID *uint `gorm:"column:attributed_project_flock_kandang_id"`
}
var rows []singleRow
if err := db.Table("(?) AS mda", MarketingDeliverySingleAttributionQuery(db)).
Order("mda.marketing_delivery_product_id ASC").
Scan(&rows).Error; err != nil {
t.Fatalf("failed scanning single attribution rows: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
if rows[0].MarketingDeliveryProductID != 601 || rows[0].AttributedProjectFlockKandangID != nil {
t.Fatalf("expected pooled delivery 601 to have nil single attribution, got %+v", rows[0])
}
if rows[1].MarketingDeliveryProductID != 602 || rows[1].AttributedProjectFlockKandangID == nil || *rows[1].AttributedProjectFlockKandangID != 101 {
t.Fatalf("expected fallback delivery 602 to map to kandang 101, got %+v", rows[1])
}
if rows[2].MarketingDeliveryProductID != 603 || rows[2].AttributedProjectFlockKandangID == nil || *rows[2].AttributedProjectFlockKandangID != 101 {
t.Fatalf("expected egg delivery 603 to map to kandang 101, got %+v", rows[2])
}
}
func setupMarketingAttributionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER,
stockable_type TEXT,
stockable_id INTEGER,
usable_type TEXT,
usable_id INTEGER,
qty NUMERIC(15,3),
status TEXT,
allocation_purpose TEXT
)`,
`CREATE TABLE project_flock_populations (id INTEGER PRIMARY KEY, project_chickin_id INTEGER)`,
`CREATE TABLE project_chickins (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER)`,
`CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER)`,
`CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, category TEXT)`,
`CREATE TABLE marketing_delivery_products (id INTEGER PRIMARY KEY, marketing_product_id INTEGER, usage_qty NUMERIC(15,3))`,
`CREATE TABLE marketing_products (id INTEGER PRIMARY KEY, product_warehouse_id INTEGER)`,
`CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE recording_eggs (id INTEGER PRIMARY KEY, recording_id INTEGER, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE recordings (id INTEGER PRIMARY KEY, project_flock_kandangs_id INTEGER NULL)`,
`CREATE TABLE recording_depletions (id INTEGER PRIMARY KEY, recording_id INTEGER, source_project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE purchase_items (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE stock_transfer_details (id INTEGER PRIMARY KEY, source_product_warehouse_id INTEGER NULL)`,
`CREATE TABLE laying_transfer_targets (id INTEGER PRIMARY KEY, target_project_flock_kandang_id INTEGER NULL)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
+3 -1
View File
@@ -57,6 +57,7 @@ var (
SSOPortalURL string
SSOClients map[string]SSOClientConfig
SSOAccessCookieName string
SSOAccessCookieFallback []string
SSORefreshCookieName string
SSOCookieDomain string
SSOCookieSecure bool
@@ -107,7 +108,7 @@ func init() {
JWTResetPasswordExp = viper.GetInt("JWT_RESET_PASSWORD_EXP_MINUTES")
JWTVerifyEmailExp = viper.GetInt("JWT_VERIFY_EMAIL_EXP_MINUTES")
//Cors
// Cors
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With")
@@ -147,6 +148,7 @@ func init() {
SSOGetMeURL = viper.GetString("SSO_GETME_URL")
SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL"))
SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access")
SSOAccessCookieFallback = parseList("SSO_ACCESS_COOKIE_FALLBACK")
SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh")
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE")
+1 -1
View File
@@ -37,7 +37,7 @@ func Connect(dbHost, dbName string) *gorm.DB {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
SkipDefaultTransaction: true,
PrepareStmt: true,
PrepareStmt: false,
TranslateError: true,
})
if err != nil {
@@ -0,0 +1,57 @@
CREATE TABLE IF NOT EXISTS project_chickins (
id BIGSERIAL PRIMARY KEY,
project_flock_kandang_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
chick_in_date DATE NOT NULL,
usage_qty NUMERIC(15, 3) NOT NULL,
pending_usage_qty NUMERIC(15, 3) DEFAULT 0,
notes TEXT,
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
DO $$
BEGIN
IF to_regclass('project_flock_kandangs') IS NOT NULL THEN
ALTER TABLE project_chickins
ADD CONSTRAINT fk_project_chickins_kandang
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
IF to_regclass('product_warehouses') IS NOT NULL THEN
ALTER TABLE project_chickins
ADD CONSTRAINT fk_project_chickins_warehouse
FOREIGN KEY (product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF to_regclass('users') IS NOT NULL THEN
ALTER TABLE project_chickins
ADD CONSTRAINT fk_project_chickins_created_by
FOREIGN KEY (created_by)
REFERENCES users(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_chickins_kandang_id ON project_chickins (project_flock_kandang_id)
WHERE
deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_chickins_warehouse_id ON project_chickins (product_warehouse_id)
WHERE
deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_chickins_created_by ON project_chickins (created_by);
CREATE INDEX IF NOT EXISTS idx_chickins_kandang_deleted ON project_chickins (
project_flock_kandang_id,
deleted_at
);
CREATE INDEX IF NOT EXISTS idx_chickins_deleted_at ON project_chickins (deleted_at);
@@ -0,0 +1,60 @@
CREATE TABLE IF NOT EXISTS project_flock_populations (
id BIGSERIAL PRIMARY KEY,
project_chickin_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
total_qty NUMERIC(15, 3) NOT NULL,
total_used_qty NUMERIC(15, 3) DEFAULT 0,
notes TEXT,
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
DO $$
BEGIN
IF to_regclass('project_chickins') IS NOT NULL THEN
ALTER TABLE project_flock_populations
ADD CONSTRAINT fk_project_flock_populations_chickin
FOREIGN KEY (project_chickin_id)
REFERENCES project_chickins(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
IF to_regclass('product_warehouses') IS NOT NULL THEN
ALTER TABLE project_flock_populations
ADD CONSTRAINT fk_project_flock_populations_warehouse
FOREIGN KEY (product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
IF to_regclass('users') IS NOT NULL THEN
ALTER TABLE project_flock_populations
ADD CONSTRAINT fk_project_flock_populations_created_by
FOREIGN KEY (created_by)
REFERENCES users(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_populations_chickin_id ON project_flock_populations (project_chickin_id)
WHERE
deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_populations_warehouse_id ON project_flock_populations (product_warehouse_id)
WHERE
deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_populations_created_by ON project_flock_populations (created_by);
CREATE INDEX IF NOT EXISTS idx_populations_chickin_deleted ON project_flock_populations (
project_chickin_id,
deleted_at
);
CREATE INDEX IF NOT EXISTS idx_populations_deleted_at ON project_flock_populations (deleted_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_populations_chickin_unique ON project_flock_populations (project_chickin_id)
WHERE
deleted_at IS NULL;
@@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS project_chickin_details (
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_chickins') THEN
IF to_regclass('project_chickins') IS NOT NULL THEN
ALTER TABLE project_chickin_details
ADD CONSTRAINT fk_project_chickin_id
FOREIGN KEY (project_chickin_id)
@@ -20,7 +20,7 @@ BEGIN
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN
IF to_regclass('product_warehouses') IS NOT NULL THEN
ALTER TABLE project_chickin_details
ADD CONSTRAINT fk_product_warehouse_id
FOREIGN KEY (product_warehouse_id)
@@ -28,7 +28,7 @@ BEGIN
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
IF to_regclass('users') IS NOT NULL THEN
ALTER TABLE project_chickin_details
ADD CONSTRAINT fk_created_by
FOREIGN KEY (created_by)
@@ -42,4 +42,4 @@ CREATE INDEX IF NOT EXISTS idx_project_chickin_details_project_chickin_id ON pro
CREATE INDEX IF NOT EXISTS idx_project_chickin_details_product_warehouse_id ON project_chickin_details (product_warehouse_id);
CREATE INDEX IF NOT EXISTS idx_project_chickin_details_created_by ON project_chickin_details (created_by);
CREATE INDEX IF NOT EXISTS idx_project_chickin_details_created_by ON project_chickin_details (created_by);
@@ -1,250 +1,6 @@
BEGIN;
INSERT INTO fifo_stock_v2_flag_groups(code, name, priority)
VALUES
('AYAM', 'AYAM', 10),
('AFKIR_CULLING_MATI', 'AFKIR/CULLING/MATI', 20),
('PAKAN', 'PAKAN', 30),
('OVK', 'OVK', 40),
('TELUR', 'TELUR', 50),
('TELUR_GRADE', 'UTUH/PUTIH/RETAK/PECAH/PAPACAL/JUMBO', 60)
ON CONFLICT (code) DO UPDATE
SET
name = EXCLUDED.name,
priority = EXCLUDED.priority,
updated_at = NOW();
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority)
VALUES
('DOC', 'AYAM', 10),
('PULLET', 'AYAM', 20),
('LAYER', 'AYAM', 30),
('AYAM-AFKIR', 'AFKIR_CULLING_MATI', 10),
('AYAM-CULLING', 'AFKIR_CULLING_MATI', 20),
('AYAM-MATI', 'AFKIR_CULLING_MATI', 30),
('PAKAN', 'PAKAN', 10),
('PRE-STARTER', 'PAKAN', 20),
('STARTER', 'PAKAN', 30),
('FINISHER', 'PAKAN', 40),
('OVK', 'OVK', 10),
('OBAT', 'OVK', 20),
('VITAMIN', 'OVK', 30),
('KIMIA', 'OVK', 40),
('TELUR', 'TELUR', 10),
('TELUR-UTUH', 'TELUR_GRADE', 10),
('TELUR-PUTIH', 'TELUR_GRADE', 20),
('TELUR-RETAK', 'TELUR_GRADE', 30),
('TELUR-PECAH', 'TELUR_GRADE', 40),
('TELUR-PAPACAL', 'TELUR_GRADE', 50),
('TELUR-JUMBO', 'TELUR_GRADE', 60)
ON CONFLICT (flag_name) DO UPDATE
SET
flag_group_code = EXCLUDED.flag_group_code,
priority = EXCLUDED.priority,
updated_at = NOW();
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
('purchase_items', 'STOCKABLE', NULL, NULL, NULL, 'received_date', NULL, 10, 'id'),
('stock_transfer_details', 'STOCKABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('stock_transfer_details', 'USABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('laying_transfer_targets', 'STOCKABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('laying_transfer_sources', 'USABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('adjustment_stocks', 'STOCKABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('adjustment_stocks', 'USABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('recording_stocks', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_eggs', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', 'created_at', 40, 'id'),
('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'),
('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id'),
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, '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;
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
('AYAM', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE),
-- AYAM USABLE
('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE),
('AYAM', 'USABLE', 'RECORDING_DEPLETION_OUT', 'recording_depletions', 'id', 'source_product_warehouse_id', 'qty', NULL, 'pending_qty', NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
('AYAM', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfer_sources', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE),
-- AFKIR/CULLING/MATI STOCKABLE
('AFKIR_CULLING_MATI', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'RECORDING_DEPLETION_IN', 'recording_depletions', 'id', 'product_warehouse_id', 'qty', NULL, NULL, NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
-- AFKIR/CULLING/MATI USABLE
('AFKIR_CULLING_MATI', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- PAKAN STOCKABLE
('PAKAN', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('PAKAN', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- PAKAN USABLE
('PAKAN', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('PAKAN', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- OVK STOCKABLE
('OVK', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('OVK', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- OVK USABLE
('OVK', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('OVK', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR STOCKABLE
('TELUR', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('TELUR', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR USABLE
('TELUR', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR_GRADE STOCKABLE
('TELUR_GRADE', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR_GRADE', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('TELUR_GRADE', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR_GRADE USABLE
('TELUR_GRADE', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', 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 = EXCLUDED.is_active,
updated_at = NOW();
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, NULL, 'USABLE', TRUE, 999, 'fifo_v2_default_allow', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code IS NULL
AND lane = 'USABLE'
AND reason = 'fifo_v2_default_allow'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'AYAM', 'RECORDING_DEPLETION_OUT', 'USABLE', FALSE, 10, 'fifo_v2_exception_ayam_depletion_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code = 'AYAM'
AND function_code = 'RECORDING_DEPLETION_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_ayam_depletion_block'
);
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 flag_group_code IS NULL
AND function_code = 'MARKETING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_marketing_block'
);
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 flag_group_code IS NULL
AND function_code = 'STOCK_TRANSFER_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'ADJUSTMENT_OUT', 'USABLE', FALSE, 40, 'fifo_v2_exception_adjustment_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'ADJUSTMENT_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_adjustment_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'TRANSFER_TO_LAYING_OUT', 'USABLE', FALSE, 50, 'fifo_v2_exception_transfer_laying_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_laying_block'
);
-- no-op: moved to 20260306090010_seed_fifo_stock_v2_config_after_core.up.sql
-- to ensure FIFO core tables exist before seeding on fresh migrations.
COMMIT;
@@ -1,20 +1,5 @@
BEGIN;
-- Restore CHICKIN route if rollback is required.
-- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration.
DO $$
BEGIN
IF to_regclass('public.fifo_stock_v2_route_rules') IS NOT NULL THEN
EXECUTE '
UPDATE fifo_stock_v2_route_rules
SET is_active = TRUE,
updated_at = NOW()
WHERE flag_group_code = ''AYAM''
AND lane = ''USABLE''
AND function_code = ''CHICKIN_OUT''
AND source_table = ''project_chickins''
';
END IF;
END $$;
-- no-op: moved to 20260306090011_disable_chickin_fifo_consumption_after_core.down.sql
COMMIT;
@@ -1,151 +1,6 @@
BEGIN;
-- Disable CHICKIN as FIFO USABLE so chick-in acts as business tagging/conversion,
-- not physical stock consumption.
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'
AND is_active = TRUE;
-- Release existing active allocations created by PROJECT_CHICKIN
-- and return warehouse qty back.
WITH released AS (
UPDATE stock_allocations
SET status = 'RELEASED',
released_at = COALESCE(released_at, NOW()),
updated_at = NOW(),
note = CASE
WHEN COALESCE(note, '') = '' THEN 'fifo_v2_chickin_conversion_release'
ELSE note || '; fifo_v2_chickin_conversion_release'
END
WHERE usable_type = 'PROJECT_CHICKIN'
AND status = 'ACTIVE'
RETURNING product_warehouse_id, qty
),
pw_delta AS (
SELECT product_warehouse_id, COALESCE(SUM(qty), 0) AS qty_delta
FROM released
GROUP BY product_warehouse_id
)
UPDATE product_warehouses pw
SET qty = COALESCE(pw.qty, 0) + d.qty_delta
FROM pw_delta d
WHERE pw.id = d.product_warehouse_id;
-- Resync stockable total_used columns from remaining ACTIVE allocations.
-- purchase_items (PURCHASE_ITEMS)
UPDATE purchase_items pi
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'PURCHASE_ITEMS'
GROUP BY stockable_id
) a
WHERE pi.id = a.stockable_id;
UPDATE purchase_items pi
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'PURCHASE_ITEMS'
AND sa.stockable_id = pi.id
);
-- stock_transfer_details (STOCK_TRANSFER_IN)
UPDATE stock_transfer_details std
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'STOCK_TRANSFER_IN'
GROUP BY stockable_id
) a
WHERE std.id = a.stockable_id;
UPDATE stock_transfer_details std
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
);
-- adjustment_stocks (ADJUSTMENT_IN)
UPDATE adjustment_stocks ast
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'ADJUSTMENT_IN'
GROUP BY stockable_id
) a
WHERE ast.id = a.stockable_id;
UPDATE adjustment_stocks ast
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'ADJUSTMENT_IN'
AND sa.stockable_id = ast.id
);
-- laying_transfer_targets (TRANSFERTOLAYING_IN)
UPDATE laying_transfer_targets ltt
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'TRANSFERTOLAYING_IN'
GROUP BY stockable_id
) a
WHERE ltt.id = a.stockable_id;
UPDATE laying_transfer_targets ltt
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'TRANSFERTOLAYING_IN'
AND sa.stockable_id = ltt.id
);
-- recording_eggs (RECORDING_EGG)
UPDATE recording_eggs re
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'RECORDING_EGG'
GROUP BY stockable_id
) a
WHERE re.id = a.stockable_id;
UPDATE recording_eggs re
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'RECORDING_EGG'
AND sa.stockable_id = re.id
);
-- no-op: moved to 20260306090011_disable_chickin_fifo_consumption_after_core.up.sql
-- to ensure FIFO core + seed are applied before this data update migration.
COMMIT;
@@ -1,9 +1,5 @@
BEGIN;
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
-- no-op: moved to 20260306090012_fix_fifo_chickin_out_after_seed.down.sql
COMMIT;
@@ -1,34 +1,6 @@
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,
updated_at = NOW(),
-- Keep existing is_active (do not override disable migration if it was intentional).
is_active = fifo_stock_v2_route_rules.is_active;
-- no-op: moved to 20260306090012_fix_fifo_chickin_out_after_seed.up.sql
-- to ensure AYAM flag group exists before route-rule upsert.
COMMIT;
@@ -1,7 +1,5 @@
BEGIN;
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_name = 'AYAM'
AND flag_group_code = 'AYAM';
-- no-op: moved to 20260306090013_add_ayam_flag_member_fifo_v2_after_seed.down.sql
COMMIT;
@@ -1,13 +1,6 @@
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();
-- no-op: moved to 20260306090013_add_ayam_flag_member_fifo_v2_after_seed.up.sql
-- to ensure AYAM flag group exists before inserting AYAM member.
COMMIT;
@@ -0,0 +1,37 @@
BEGIN;
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'
);
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
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'
);
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
DELETE FROM fifo_stock_v2_flag_groups
WHERE code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
COMMIT;
@@ -0,0 +1,250 @@
BEGIN;
INSERT INTO fifo_stock_v2_flag_groups(code, name, priority)
VALUES
('AYAM', 'AYAM', 10),
('AFKIR_CULLING_MATI', 'AFKIR/CULLING/MATI', 20),
('PAKAN', 'PAKAN', 30),
('OVK', 'OVK', 40),
('TELUR', 'TELUR', 50),
('TELUR_GRADE', 'UTUH/PUTIH/RETAK/PECAH/PAPACAL/JUMBO', 60)
ON CONFLICT (code) DO UPDATE
SET
name = EXCLUDED.name,
priority = EXCLUDED.priority,
updated_at = NOW();
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority)
VALUES
('DOC', 'AYAM', 10),
('PULLET', 'AYAM', 20),
('LAYER', 'AYAM', 30),
('AYAM-AFKIR', 'AFKIR_CULLING_MATI', 10),
('AYAM-CULLING', 'AFKIR_CULLING_MATI', 20),
('AYAM-MATI', 'AFKIR_CULLING_MATI', 30),
('PAKAN', 'PAKAN', 10),
('PRE-STARTER', 'PAKAN', 20),
('STARTER', 'PAKAN', 30),
('FINISHER', 'PAKAN', 40),
('OVK', 'OVK', 10),
('OBAT', 'OVK', 20),
('VITAMIN', 'OVK', 30),
('KIMIA', 'OVK', 40),
('TELUR', 'TELUR', 10),
('TELUR-UTUH', 'TELUR_GRADE', 10),
('TELUR-PUTIH', 'TELUR_GRADE', 20),
('TELUR-RETAK', 'TELUR_GRADE', 30),
('TELUR-PECAH', 'TELUR_GRADE', 40),
('TELUR-PAPACAL', 'TELUR_GRADE', 50),
('TELUR-JUMBO', 'TELUR_GRADE', 60)
ON CONFLICT (flag_name) DO UPDATE
SET
flag_group_code = EXCLUDED.flag_group_code,
priority = EXCLUDED.priority,
updated_at = NOW();
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
('purchase_items', 'STOCKABLE', NULL, NULL, NULL, 'received_date', NULL, 10, 'id'),
('stock_transfer_details', 'STOCKABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('stock_transfer_details', 'USABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('laying_transfer_targets', 'STOCKABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('laying_transfer_sources', 'USABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('adjustment_stocks', 'STOCKABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('adjustment_stocks', 'USABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('recording_stocks', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_eggs', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', 'created_at', 40, 'id'),
('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'),
('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id'),
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, '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;
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
('AYAM', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE),
-- AYAM USABLE
('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE),
('AYAM', 'USABLE', 'RECORDING_DEPLETION_OUT', 'recording_depletions', 'id', 'source_product_warehouse_id', 'qty', NULL, 'pending_qty', NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
('AYAM', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfer_sources', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE),
-- AFKIR/CULLING/MATI STOCKABLE
('AFKIR_CULLING_MATI', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'RECORDING_DEPLETION_IN', 'recording_depletions', 'id', 'product_warehouse_id', 'qty', NULL, NULL, NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
-- AFKIR/CULLING/MATI USABLE
('AFKIR_CULLING_MATI', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- PAKAN STOCKABLE
('PAKAN', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('PAKAN', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- PAKAN USABLE
('PAKAN', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('PAKAN', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- OVK STOCKABLE
('OVK', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('OVK', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- OVK USABLE
('OVK', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('OVK', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR STOCKABLE
('TELUR', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('TELUR', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR USABLE
('TELUR', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR_GRADE STOCKABLE
('TELUR_GRADE', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR_GRADE', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('TELUR_GRADE', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR_GRADE USABLE
('TELUR_GRADE', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', 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 = EXCLUDED.is_active,
updated_at = NOW();
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, NULL, 'USABLE', TRUE, 999, 'fifo_v2_default_allow', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code IS NULL
AND lane = 'USABLE'
AND reason = 'fifo_v2_default_allow'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'AYAM', 'RECORDING_DEPLETION_OUT', 'USABLE', FALSE, 10, 'fifo_v2_exception_ayam_depletion_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code = 'AYAM'
AND function_code = 'RECORDING_DEPLETION_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_ayam_depletion_block'
);
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 flag_group_code IS NULL
AND function_code = 'MARKETING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_marketing_block'
);
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 flag_group_code IS NULL
AND function_code = 'STOCK_TRANSFER_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'ADJUSTMENT_OUT', 'USABLE', FALSE, 40, 'fifo_v2_exception_adjustment_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'ADJUSTMENT_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_adjustment_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'TRANSFER_TO_LAYING_OUT', 'USABLE', FALSE, 50, 'fifo_v2_exception_transfer_laying_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_laying_block'
);
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
-- Restore CHICKIN route if rollback is required.
-- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration.
UPDATE fifo_stock_v2_route_rules
SET is_active = TRUE,
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,151 @@
BEGIN;
-- Disable CHICKIN as FIFO USABLE so chick-in acts as business tagging/conversion,
-- not physical stock consumption.
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'
AND is_active = TRUE;
-- Release existing active allocations created by PROJECT_CHICKIN
-- and return warehouse qty back.
WITH released AS (
UPDATE stock_allocations
SET status = 'RELEASED',
released_at = COALESCE(released_at, NOW()),
updated_at = NOW(),
note = CASE
WHEN COALESCE(note, '') = '' THEN 'fifo_v2_chickin_conversion_release'
ELSE note || '; fifo_v2_chickin_conversion_release'
END
WHERE usable_type = 'PROJECT_CHICKIN'
AND status = 'ACTIVE'
RETURNING product_warehouse_id, qty
),
pw_delta AS (
SELECT product_warehouse_id, COALESCE(SUM(qty), 0) AS qty_delta
FROM released
GROUP BY product_warehouse_id
)
UPDATE product_warehouses pw
SET qty = COALESCE(pw.qty, 0) + d.qty_delta
FROM pw_delta d
WHERE pw.id = d.product_warehouse_id;
-- Resync stockable total_used columns from remaining ACTIVE allocations.
-- purchase_items (PURCHASE_ITEMS)
UPDATE purchase_items pi
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'PURCHASE_ITEMS'
GROUP BY stockable_id
) a
WHERE pi.id = a.stockable_id;
UPDATE purchase_items pi
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'PURCHASE_ITEMS'
AND sa.stockable_id = pi.id
);
-- stock_transfer_details (STOCK_TRANSFER_IN)
UPDATE stock_transfer_details std
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'STOCK_TRANSFER_IN'
GROUP BY stockable_id
) a
WHERE std.id = a.stockable_id;
UPDATE stock_transfer_details std
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
);
-- adjustment_stocks (ADJUSTMENT_IN)
UPDATE adjustment_stocks ast
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'ADJUSTMENT_IN'
GROUP BY stockable_id
) a
WHERE ast.id = a.stockable_id;
UPDATE adjustment_stocks ast
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'ADJUSTMENT_IN'
AND sa.stockable_id = ast.id
);
-- laying_transfer_targets (TRANSFERTOLAYING_IN)
UPDATE laying_transfer_targets ltt
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'TRANSFERTOLAYING_IN'
GROUP BY stockable_id
) a
WHERE ltt.id = a.stockable_id;
UPDATE laying_transfer_targets ltt
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'TRANSFERTOLAYING_IN'
AND sa.stockable_id = ltt.id
);
-- recording_eggs (RECORDING_EGG)
UPDATE recording_eggs re
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'RECORDING_EGG'
GROUP BY stockable_id
) a
WHERE re.id = a.stockable_id;
UPDATE recording_eggs re
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'RECORDING_EGG'
AND sa.stockable_id = re.id
);
COMMIT;
@@ -0,0 +1,9 @@
BEGIN;
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT;
@@ -0,0 +1,34 @@
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,
updated_at = NOW(),
-- Keep existing is_active (do not override disable migration if it was intentional).
is_active = fifo_stock_v2_route_rules.is_active;
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,28 @@
BEGIN;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS fk_daily_checklists_kandang;
UPDATE daily_checklists dc
SET kandang_id = k.id
FROM kandangs k
WHERE
dc.kandang_id = k.kandang_group_id;
ALTER TABLE daily_checklists
ADD CONSTRAINT fk_daily_checklists_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs (id) ON DELETE CASCADE;
DROP INDEX IF EXISTS idx_kandangs_kandang_group_id;
ALTER TABLE kandangs
DROP CONSTRAINT IF EXISTS fk_kandangs_kandang_group;
ALTER TABLE kandangs
DROP COLUMN IF EXISTS kandang_group_id;
DROP INDEX IF EXISTS kandang_groups_name_unique;
DROP TABLE IF EXISTS kandang_groups;
COMMIT;
@@ -0,0 +1,91 @@
BEGIN;
CREATE TABLE kandang_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL,
location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE,
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX kandang_groups_name_unique ON kandang_groups (name)
WHERE
deleted_at IS NULL;
ALTER TABLE kandangs
ADD COLUMN kandang_group_id BIGINT;
CREATE TEMP TABLE tmp_kandang_group_map (
kandang_id BIGINT PRIMARY KEY,
kandang_group_id BIGINT NOT NULL
) ON COMMIT DROP;
INSERT INTO tmp_kandang_group_map (kandang_id, kandang_group_id)
SELECT
k.id,
nextval(pg_get_serial_sequence('kandang_groups', 'id'))
FROM kandangs k
ORDER BY
k.id;
INSERT INTO kandang_groups (
id,
name,
status,
location_id,
pic_id,
created_at,
updated_at,
deleted_at,
created_by
)
SELECT
m.kandang_group_id,
k.name,
k.status,
k.location_id,
CASE WHEN pic.id IS NOT NULL THEN k.pic_id ELSE NULL END,
k.created_at,
k.updated_at,
k.deleted_at,
CASE WHEN creator.id IS NOT NULL THEN k.created_by ELSE NULL END
FROM kandangs k
JOIN tmp_kandang_group_map m ON m.kandang_id = k.id
LEFT JOIN users pic ON pic.id = k.pic_id
LEFT JOIN users creator ON creator.id = k.created_by
ORDER BY
k.id;
UPDATE kandangs k
SET kandang_group_id = m.kandang_group_id
FROM tmp_kandang_group_map m
WHERE
m.kandang_id = k.id;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS fk_daily_checklists_kandang;
UPDATE daily_checklists dc
SET kandang_id = m.kandang_group_id
FROM tmp_kandang_group_map m
WHERE
dc.kandang_id = m.kandang_id;
ALTER TABLE daily_checklists
ADD CONSTRAINT fk_daily_checklists_kandang
FOREIGN KEY (kandang_id) REFERENCES kandang_groups (id) ON DELETE CASCADE;
ALTER TABLE kandangs
ALTER COLUMN kandang_group_id SET NOT NULL;
ALTER TABLE kandangs
ADD CONSTRAINT fk_kandangs_kandang_group
FOREIGN KEY (kandang_group_id) REFERENCES kandang_groups (id) ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX idx_kandangs_kandang_group_id ON kandangs (kandang_group_id);
COMMIT;
@@ -0,0 +1,54 @@
BEGIN;
ALTER TABLE employee_kandangs
DROP CONSTRAINT IF EXISTS fk_employee_kandangs_kandang;
ALTER TABLE employee_kandangs
DROP CONSTRAINT IF EXISTS uq_employee_kandangs;
CREATE TEMP TABLE tmp_kandang_group_to_kandang_map (
kandang_group_id BIGINT PRIMARY KEY,
kandang_id BIGINT NOT NULL
) ON COMMIT DROP;
INSERT INTO tmp_kandang_group_to_kandang_map (kandang_group_id, kandang_id)
SELECT
k.kandang_group_id,
MIN(k.id) AS kandang_id
FROM kandangs k
WHERE k.kandang_group_id IS NOT NULL
GROUP BY k.kandang_group_id;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM employee_kandangs ek
LEFT JOIN tmp_kandang_group_to_kandang_map m ON m.kandang_group_id = ek.kandang_id
WHERE m.kandang_id IS NULL
) THEN
RAISE EXCEPTION 'Cannot rollback employee_kandangs migration: kandang_group_id has no kandang mapping';
END IF;
END $$;
UPDATE employee_kandangs ek
SET kandang_id = m.kandang_id,
updated_at = NOW()
FROM tmp_kandang_group_to_kandang_map m
WHERE m.kandang_group_id = ek.kandang_id;
DELETE FROM employee_kandangs ek1
USING employee_kandangs ek2
WHERE ek1.id > ek2.id
AND ek1.employee_id = ek2.employee_id
AND ek1.kandang_id = ek2.kandang_id;
ALTER TABLE employee_kandangs
ADD CONSTRAINT fk_employee_kandangs_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs (id)
ON DELETE CASCADE;
ALTER TABLE employee_kandangs
ADD CONSTRAINT uq_employee_kandangs UNIQUE (employee_id, kandang_id);
COMMIT;
@@ -0,0 +1,41 @@
BEGIN;
ALTER TABLE employee_kandangs
DROP CONSTRAINT IF EXISTS fk_employee_kandangs_kandang;
ALTER TABLE employee_kandangs
DROP CONSTRAINT IF EXISTS uq_employee_kandangs;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM employee_kandangs ek
LEFT JOIN kandangs k ON k.id = ek.kandang_id
WHERE k.kandang_group_id IS NULL
) THEN
RAISE EXCEPTION 'Cannot migrate employee_kandangs: kandang_id has no kandang_group_id mapping';
END IF;
END $$;
UPDATE employee_kandangs ek
SET kandang_id = k.kandang_group_id,
updated_at = NOW()
FROM kandangs k
WHERE k.id = ek.kandang_id;
DELETE FROM employee_kandangs ek1
USING employee_kandangs ek2
WHERE ek1.id > ek2.id
AND ek1.employee_id = ek2.employee_id
AND ek1.kandang_id = ek2.kandang_id;
ALTER TABLE employee_kandangs
ADD CONSTRAINT fk_employee_kandangs_kandang
FOREIGN KEY (kandang_id) REFERENCES kandang_groups (id)
ON DELETE CASCADE;
ALTER TABLE employee_kandangs
ADD CONSTRAINT uq_employee_kandangs UNIQUE (employee_id, kandang_id);
COMMIT;
@@ -0,0 +1,18 @@
BEGIN;
DROP INDEX IF EXISTS idx_recording_depletions_source_project_flock_kandang_id;
DROP INDEX IF EXISTS idx_recording_eggs_project_flock_kandang_id;
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS fk_recording_depletions_source_project_flock_kandang_id;
ALTER TABLE recording_eggs
DROP CONSTRAINT IF EXISTS fk_recording_eggs_project_flock_kandang_id;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS source_project_flock_kandang_id;
ALTER TABLE recording_eggs
DROP COLUMN IF EXISTS project_flock_kandang_id;
COMMIT;
@@ -0,0 +1,61 @@
BEGIN;
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS source_project_flock_kandang_id BIGINT NULL;
ALTER TABLE recording_eggs
ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_recording_depletions_source_project_flock_kandang_id'
) THEN
ALTER TABLE recording_depletions
ADD CONSTRAINT fk_recording_depletions_source_project_flock_kandang_id
FOREIGN KEY (source_project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_recording_eggs_project_flock_kandang_id'
) THEN
ALTER TABLE recording_eggs
ADD CONSTRAINT fk_recording_eggs_project_flock_kandang_id
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_recording_depletions_source_project_flock_kandang_id
ON recording_depletions(source_project_flock_kandang_id);
CREATE INDEX IF NOT EXISTS idx_recording_eggs_project_flock_kandang_id
ON recording_eggs(project_flock_kandang_id);
UPDATE recording_depletions rd
SET source_project_flock_kandang_id = r.project_flock_kandangs_id
FROM recordings r
WHERE r.id = rd.recording_id
AND rd.source_project_flock_kandang_id IS NULL
AND r.project_flock_kandangs_id IS NOT NULL;
UPDATE recording_eggs re
SET project_flock_kandang_id = r.project_flock_kandangs_id
FROM recordings r
WHERE r.id = re.recording_id
AND re.project_flock_kandang_id IS NULL
AND r.project_flock_kandangs_id IS NOT NULL;
COMMIT;
@@ -0,0 +1,9 @@
BEGIN;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
ALTER TABLE daily_checklists
ADD CONSTRAINT daily_checklists_date_kandang_category_key
UNIQUE (date, kandang_id, category);
COMMIT;
@@ -0,0 +1,10 @@
BEGIN;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected
ON daily_checklists (date, kandang_id, category)
WHERE (status IS NULL OR status <> 'REJECTED');
COMMIT;
+1 -1
View File
@@ -17,7 +17,7 @@ type DailyChecklist struct {
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
+2 -2
View File
@@ -26,6 +26,6 @@ type EmployeeKandang struct {
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
Employee Employee `gorm:"foreignKey:EmployeeId;references:Id"`
Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"`
Employee Employee `gorm:"foreignKey:EmployeeId;references:Id"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
}
+2
View File
@@ -11,6 +11,7 @@ type Kandang struct {
Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"`
Status string `gorm:"type:varchar(50);not null"`
LocationId uint `gorm:"not null"`
KandangGroupId uint `gorm:"not null"`
Capacity float64 `gorm:"not null"`
PicId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"`
@@ -19,6 +20,7 @@ type Kandang struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"`
KandangGroup KandangGroup `gorm:"foreignKey:KandangGroupId;references:Id"`
Pic User `gorm:"foreignKey:PicId;references:Id"`
Warehouses []Warehouse `gorm:"foreignKey:KandangId;references:Id"`
ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"`
+24
View File
@@ -0,0 +1,24 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type KandangGroup struct {
Id uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandang_groups_name_unique,where:deleted_at IS NULL"`
Status string `gorm:"type:varchar(50);not null"`
LocationId uint `gorm:"not null"`
PicId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"`
Pic User `gorm:"foreignKey:PicId;references:Id"`
Kandangs []Kandang `gorm:"foreignKey:KandangGroupId;references:Id"`
}
+12 -10
View File
@@ -5,20 +5,22 @@ import (
)
type MarketingDeliveryProduct struct {
Id uint `gorm:"primaryKey;autoIncrement"`
MarketingProductId uint `gorm:"uniqueIndex;not null"`
ProductWarehouseId uint `gorm:"not null"`
UnitPrice float64 `gorm:"type:numeric(15,3)"`
TotalWeight float64 `gorm:"type:numeric(15,3)"`
AvgWeight float64 `gorm:"type:numeric(15,3)"`
TotalPrice float64 `gorm:"type:numeric(15,3)"`
DeliveryDate *time.Time `gorm:"type:timestamptz"`
VehicleNumber string `gorm:"type:varchar(50)"`
Id uint `gorm:"primaryKey;autoIncrement"`
MarketingProductId uint `gorm:"uniqueIndex;not null"`
ProductWarehouseId uint `gorm:"not null"`
AttributedProjectFlockKandangId *uint `gorm:"->;column:attributed_project_flock_kandang_id"`
UnitPrice float64 `gorm:"type:numeric(15,3)"`
TotalWeight float64 `gorm:"type:numeric(15,3)"`
AvgWeight float64 `gorm:"type:numeric(15,3)"`
TotalPrice float64 `gorm:"type:numeric(15,3)"`
DeliveryDate *time.Time `gorm:"type:timestamptz"`
VehicleNumber string `gorm:"type:varchar(50)"`
// FIFO Fields
UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
CreatedAt *time.Time `gorm:"type:timestamptz;not null"`
MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"`
MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"`
AttributedProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:AttributedProjectFlockKandangId;references:Id"`
}
+11 -9
View File
@@ -1,14 +1,16 @@
package entities
type RecordingDepletion struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
Qty float64 `gorm:"column:qty;not null"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
SourceProjectFlockKandangId *uint `gorm:"column:source_project_flock_kandang_id"`
Qty float64 `gorm:"column:qty;not null"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"`
}
+16 -14
View File
@@ -3,18 +3,20 @@ package entities
import "time"
type RecordingEgg struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Qty int `gorm:"column:qty;not null"`
TotalQty float64 `gorm:"column:total_qty"`
TotalUsed float64 `gorm:"column:total_used"`
Weight *float64 `gorm:"column:weight"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
Qty int `gorm:"column:qty;not null"`
TotalQty float64 `gorm:"column:total_qty"`
TotalUsed float64 `gorm:"column:total_used"`
Weight *float64 `gorm:"column:weight"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
}
+59 -13
View File
@@ -19,11 +19,11 @@ const (
// AuthContext keeps authentication details captured by the middleware.
type AuthContext struct {
Token string
Verification *sso.VerificationResult
User *entity.User
Roles []sso.Role
Permissions map[string]struct{}
Token string
Verification *sso.VerificationResult
User *entity.User
Roles []sso.Role
Permissions map[string]struct{}
UserAreaIDs []uint
UserLocationIDs []uint
UserAllArea bool
@@ -36,8 +36,30 @@ type AuthContext struct {
func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
token := bearerToken(c)
if token == "" {
token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName))
tokenSource := ""
if token != "" {
tokenSource = "header"
} else {
primaryName := strings.TrimSpace(config.SSOAccessCookieName)
if primaryName != "" {
token = strings.TrimSpace(c.Cookies(primaryName))
if token != "" {
tokenSource = "cookie:" + primaryName
}
}
if token == "" {
for _, name := range config.SSOAccessCookieFallback {
name = strings.TrimSpace(name)
if name == "" || name == primaryName {
continue
}
token = strings.TrimSpace(c.Cookies(name))
if token != "" {
tokenSource = "cookie:" + name
break
}
}
}
}
if token == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
@@ -45,7 +67,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
verification, err := sso.VerifyAccessToken(token)
if err != nil {
utils.Log.WithError(err).Warn("auth: token verification failed")
if sso.IsSignatureError(err) {
logSignatureError("auth", tokenSource, token, err)
} else {
utils.Log.WithError(err).Warn("auth: token verification failed")
}
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
@@ -89,11 +115,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
}
ctx := &AuthContext{
Token: token,
Verification: verification,
User: user,
Roles: roles,
Permissions: permissions,
Token: token,
Verification: verification,
User: user,
Roles: roles,
Permissions: permissions,
UserAreaIDs: nil,
UserLocationIDs: nil,
UserAllArea: false,
@@ -216,6 +242,26 @@ func hasAllScopes(have, required []string) bool {
return true
}
func logSignatureError(ctxLabel, tokenSource, token string, err error) {
info := sso.ExtractTokenInfo(token)
aud := strings.Join(info.Aud, ",")
utils.Log.Errorf(
"access token verification failed: %v | ctx=%s source=%s iss=%s kid=%s aud=%s sub=%s exp=%d iat=%d nbf=%d expected_iss=%s expected_aud=%v",
err,
ctxLabel,
tokenSource,
info.Iss,
info.Kid,
aud,
info.Sub,
info.Exp,
info.Iat,
info.Nbf,
config.SSOIssuer,
config.SSOAllowedAudiences,
)
}
// RequirePermissions ensures the authenticated user possesses all specified permissions.
func RequirePermissions(perms ...string) fiber.Handler {
required := canonicalPermissions(perms)
+2
View File
@@ -132,6 +132,8 @@ const (
P_KandangsUpdateOne = "lti.master.kandangs.update"
P_KandangsDeleteOne = "lti.master.kandangs.delete"
P_KandangGroups = "lti.daily_checklist.master_data.kandang"
P_LocationsGetAll = "lti.master.locations.list"
P_LocationsGetOne = "lti.master.locations.detail"
P_LocationsCreateOne = "lti.master.locations.create"
@@ -44,6 +44,7 @@ type PenjualanRealisasiResponseDTO struct {
// === Mapper Functions ===
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e)
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
@@ -51,11 +52,11 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
if projectFlockKandang != nil {
category = projectFlockKandang.ProjectFlock.Category
}
ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
ageInDay, ageInWeeks := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category)
var product *productDTO.ProductRelationDTO
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
@@ -70,8 +71,8 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
var kandang *kandangDTO.KandangRelationDTO
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil && e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang)
if projectFlockKandang != nil && projectFlockKandang.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(projectFlockKandang.Kandang)
kandang = &mapped
}
@@ -102,6 +103,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e)
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
@@ -109,11 +111,11 @@ func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
if projectFlockKandang != nil {
category = projectFlockKandang.ProjectFlock.Category
}
ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
ageInDay, _ := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category)
return SalesDTO{
Age: ageInDay,
@@ -164,6 +166,13 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua
}
}
func resolveMarketingDeliveryProjectFlockKandang(e entity.MarketingDeliveryProduct) *entity.ProjectFlockKandang {
if e.AttributedProjectFlockKandang != nil {
return e.AttributedProjectFlockKandang
}
return e.MarketingProduct.ProductWarehouse.ProjectFlockKandang
}
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0, 0
@@ -1,8 +1,6 @@
package dto
import (
"encoding/json"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
@@ -71,7 +69,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal
return dto
}
func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int, projectFlockKandangCountMap map[uint]int) OverheadListDTO {
func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int) OverheadListDTO {
overheadsByNonstockID := make(map[uint]*OverheadDTO)
latestDateByNonstockID := make(map[uint]string)
@@ -113,35 +111,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex
qty := realizations[i].Qty
totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price)
// Farm-level expense division
if realizations[i].ExpenseNonstock.Expense != nil &&
realizations[i].ExpenseNonstock.Expense.ProjectFlockId != nil {
projectFlockIDs := parseProjectFlockIDsFromJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId)
if len(projectFlockIDs) > 0 {
totalKandangInAllProjects := 0
for _, pfID := range projectFlockIDs {
if count, exists := projectFlockKandangCountMap[pfID]; exists {
totalKandangInAllProjects += count
}
}
if totalKandangInAllProjects > 0 {
if isPerKandang {
qty = qty / float64(totalKandangInAllProjects)
totalAmount = totalAmount / float64(totalKandangInAllProjects)
} else {
// Overhead ALL: divide by total kandang then multiply by this project's kandang count
perKandangAmount := totalAmount / float64(totalKandangInAllProjects)
perKandangQty := qty / float64(totalKandangInAllProjects)
qty = perKandangQty * float64(totalKandangCount)
totalAmount = perKandangAmount * float64(totalKandangCount)
}
}
}
}
overheadsByNonstockID[nonstockID].ActualQuantity += qty
overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount
@@ -191,27 +160,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex
}
}
func parseProjectFlockIDsFromJSON(projectFlockJSON string) []uint {
if projectFlockJSON == "" {
return []uint{}
}
var projectFlocks []uint
if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil {
return []uint{}
}
return projectFlocks
}
func countProjectFlocksInJSON(projectFlockJSON string) int {
projectFlocks := parseProjectFlockIDsFromJSON(projectFlockJSON)
if len(projectFlocks) == 0 {
return 1
}
return len(projectFlocks)
}
func getItemInfo(nonstock *entity.Nonstock) (string, string) {
if nonstock != nil && nonstock.Id != 0 {
return nonstock.Name, nonstock.Uom.Name
@@ -65,7 +65,7 @@ type SapronakCategoryRowDTO struct {
QtyOut float64 `json:"qty_out"`
QtyUsed float64 `json:"qty_used"`
Description string `json:"description"`
ProductCategory []string `json:"product_category"`
ProductCategory string `json:"product_category"`
UnitPrice float64 `json:"unit_price"`
TotalAmount float64 `json:"total_amount"`
Notes string `json:"notes"`
@@ -183,13 +183,13 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
"PULLET": 0,
}
buildFlagList := func(productID uint, fallback string) []string {
buildFlagList := func(productID uint, fallback string) string {
rawFlags := productFlags[productID]
if len(rawFlags) == 0 {
if fallback == "" {
return []string{}
return ""
}
return []string{fallback}
return fallback
}
seen := make(map[string]struct{}, len(rawFlags))
ordered := make([]string, 0, len(rawFlags))
@@ -220,7 +220,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
return li < lj
})
return ordered
return strings.Join(ordered, " ")
}
for _, group := range report.Groups {
@@ -317,6 +317,27 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
}
// For chicken categories, keep qty_used aligned with qty_in - qty_out.
// Sales are excluded; usage represents remaining after transfers.
adjustChicken := func(cat *SapronakCategoryDTO) {
if cat == nil {
return
}
for i := range cat.Rows {
row := &cat.Rows[i]
remaining := row.QtyIn - row.QtyOut
if remaining < 0 {
remaining = 0
}
row.QtyUsed = remaining
if row.UnitPrice > 0 {
row.TotalAmount = row.QtyUsed * row.UnitPrice
}
}
}
adjustChicken(result.Doc)
adjustChicken(result.Pullet)
buildTotals := func(cat *SapronakCategoryDTO, label string) {
if cat == nil {
return
@@ -345,5 +366,22 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
buildTotals(result.Doc, "TOTAL DOC")
buildTotals(result.Ovk, "TOTAL OVK")
buildTotals(result.Pakan, "TOTAL PAKAN")
// For chicken categories, enforce total qty_used = qty_in - qty_out.
adjustChickenTotal := func(cat *SapronakCategoryDTO) {
if cat == nil {
return
}
remaining := cat.Total.QtyIn - cat.Total.QtyOut
if remaining < 0 {
remaining = 0
}
cat.Total.QtyUsed = remaining
if cat.Total.AvgUnitPrice > 0 {
cat.Total.TotalAmount = cat.Total.AvgUnitPrice * remaining
}
}
adjustChickenTotal(result.Doc)
adjustChickenTotal(result.Pullet)
return result
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
package repository
import (
"context"
"fmt"
"testing"
"time"
"github.com/glebarez/sqlite"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
func TestSapronakIncomingPurchaseQueryPartsUsesAttributedPurchasesWhenProjectFlockKandangIDsProvided(t *testing.T) {
sql, args := sapronakIncomingPurchaseQueryParts(SapronakQueryParams{
WarehouseIDs: []uint{46},
ProjectFlockKandangIDs: []uint{101},
})
if sql != sapronakIncomingPurchasesScopedSQL() {
t.Fatalf("expected scoped purchase SQL, got %q", sql)
}
if len(args) != 8 {
t.Fatalf("expected 8 argument groups, got %d", len(args))
}
}
func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWarehouseFallback(t *testing.T) {
db := setupClosingRepositoryTestDB(t)
repo := NewClosingRepository(db)
ctx := context.Background()
receivedAt := time.Date(2026, 4, 1, 4, 0, 0, 0, time.UTC)
statements := []string{
`INSERT INTO warehouses (id, kandang_id) VALUES (1, NULL), (2, 59), (3, 88)`,
`INSERT INTO product_categories (id, code) VALUES (1, 'OBT'), (2, 'RAW')`,
`INSERT INTO products (id, name, product_category_id, product_price) VALUES
(10, 'MEFISTO @1 LITER', 1, 261700),
(20, 'PAKAN GROWING CRUMBLE MALINDO', 2, 15000)`,
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES
(1, 10, 'products', 'OVK'),
(2, 10, 'products', 'OBAT')`,
`INSERT INTO purchases (id, po_number, deleted_at) VALUES (1, 'PO-LTI-0005', NULL)`,
`INSERT INTO recordings (id, project_flock_kandangs_id, deleted_at) VALUES (11, 101, NULL), (12, 999, NULL)`,
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty) VALUES (21, 11, 501, 150), (22, 12, 502, 10)`,
`INSERT INTO purchase_items (id, purchase_id, product_id, warehouse_id, project_flock_kandang_id, total_qty, price, received_date) VALUES
(1, 1, 10, 1, NULL, 100, 261700, '` + receivedAt.Format(time.RFC3339) + `'),
(2, 1, 20, 1, NULL, 50, 15000, '` + receivedAt.Format(time.RFC3339) + `'),
(3, 1, 20, 2, NULL, 25, 12000, '` + receivedAt.Format(time.RFC3339) + `'),
(4, 1, 10, 3, 999, 10, 261700, '` + receivedAt.Format(time.RFC3339) + `'),
(5, 1, 20, 1, NULL, 40, 15000, '` + receivedAt.Format(time.RFC3339) + `')`,
fmt.Sprintf(`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, allocation_purpose, status) VALUES
(1, 701, '%s', 1, '%s', 21, 100, 'CONSUME', 'ACTIVE'),
(2, 702, '%s', 2, '%s', 21, 50, 'CONSUME', 'ACTIVE'),
(3, 703, '%s', 5, '%s', 22, 40, 'CONSUME', 'ACTIVE')`,
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyRecordingStock.String(),
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyRecordingStock.String(),
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyRecordingStock.String(),
),
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding schema: %v", err)
}
}
rows, err := repo.FetchSapronakIncoming(ctx, 101, 59, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 sapronak rows, got %d", len(rows))
}
byProduct := make(map[uint]SapronakIncomingRow, len(rows))
for _, row := range rows {
byProduct[row.ProductID] = row
}
if got := byProduct[10]; got.ProductID == 0 || got.Flag != "OVK" || got.Qty != 100 {
t.Fatalf("expected OVK farm purchase qty 100 for product 10, got %+v", got)
}
if got := byProduct[20]; got.ProductID == 0 || got.Flag != "PAKAN" || got.Qty != 75 {
t.Fatalf("expected PAKAN total qty 75 including farm allocated qty 50 and kandang receipt qty 25, got %+v", got)
}
}
func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY,
kandang_id INTEGER NULL
)`,
`CREATE TABLE product_categories (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL
)`,
`CREATE TABLE uoms (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)`,
`CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
product_category_id INTEGER NULL,
uom_id INTEGER NULL,
product_price NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
`CREATE TABLE flags (
id INTEGER PRIMARY KEY,
flagable_id INTEGER NOT NULL,
flagable_type TEXT NOT NULL,
name TEXT NOT NULL
)`,
`CREATE TABLE purchases (
id INTEGER PRIMARY KEY,
po_number TEXT NULL,
notes TEXT NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE purchase_items (
id INTEGER PRIMARY KEY,
purchase_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
received_date TIMESTAMP NULL
)`,
`CREATE TABLE recordings (
id INTEGER PRIMARY KEY,
project_flock_kandangs_id INTEGER NOT NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE recording_stocks (
id INTEGER PRIMARY KEY,
recording_id INTEGER NOT NULL,
product_warehouse_id INTEGER NOT NULL,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NOT NULL,
stockable_type TEXT NOT NULL,
stockable_id INTEGER NOT NULL,
usable_type TEXT NOT NULL,
usable_id INTEGER NOT NULL,
qty NUMERIC(15,3) NOT NULL DEFAULT 0,
allocation_purpose TEXT NOT NULL,
status TEXT NOT NULL
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE stock_transfers (
id INTEGER PRIMARY KEY,
from_warehouse_id INTEGER NULL,
to_warehouse_id INTEGER NULL,
transfer_date TIMESTAMP NULL,
movement_number TEXT NULL,
reason TEXT NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY,
stock_transfer_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
dest_product_warehouse_id INTEGER NULL,
source_product_warehouse_id INTEGER NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
`CREATE TABLE adjustment_stocks (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NOT NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
adj_number TEXT NULL,
created_at TIMESTAMP NULL
)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -2,7 +2,6 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
@@ -33,6 +32,14 @@ import (
"gorm.io/gorm"
)
type activeKandangMetric struct {
ProjectFlockKandangID uint
ProjectFlockID uint
KandangID uint
Category string
Metric float64
}
type ClosingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error)
GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
@@ -376,7 +383,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
var projectFlockKandangIDs []uint
if params.KandangID != nil && *params.KandangID > 0 {
projectFlockKandangIDs = []uint{*params.KandangID}
} else if params.Type == validation.SapronakTypeOutgoing {
} else {
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
@@ -385,6 +392,11 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
}
offset := (params.Page - 1) * params.Limit
startDate, endDate, err := s.getSapronakDateRange(c.Context(), projectFlockID, params.KandangID)
if err != nil {
s.Log.Errorf("Failed to resolve sapronak date range for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve sapronak date range")
}
rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{
Type: params.Type,
WarehouseIDs: warehouseIDs,
@@ -392,6 +404,8 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
Limit: params.Limit,
Offset: offset,
Search: params.Search,
StartDate: startDate,
EndDate: endDate,
})
if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -460,7 +474,7 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
var projectFlockKandangIDs []uint
if params.KandangID != nil && *params.KandangID > 0 {
projectFlockKandangIDs = []uint{*params.KandangID}
} else if params.Type == validation.SapronakTypeOutgoing {
} else {
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
@@ -468,11 +482,19 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
}
}
startDate, endDate, err := s.getSapronakDateRange(c.Context(), projectFlockID, params.KandangID)
if err != nil {
s.Log.Errorf("Failed to resolve sapronak date range for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve sapronak date range")
}
rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{
Type: params.Type,
WarehouseIDs: warehouseIDs,
ProjectFlockKandangIDs: projectFlockKandangIDs,
Search: params.Search,
StartDate: startDate,
EndDate: endDate,
})
if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -542,6 +564,90 @@ func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFl
return ids, nil
}
func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID uint, kandangID *uint) (*time.Time, *time.Time, error) {
db := s.Repository.DB().WithContext(ctx)
if kandangID != nil && *kandangID > 0 {
var pfk entity.ProjectFlockKandang
if err := db.Select("id, created_at, closed_at").First(&pfk, *kandangID).Error; err != nil {
return nil, nil, err
}
var minChickin *time.Time
if err := db.Table("project_chickins").
Select("MIN(chick_in_date)").
Where("project_flock_kandang_id = ?", pfk.Id).
Scan(&minChickin).Error; err != nil {
return nil, nil, err
}
start := pfk.CreatedAt
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
}
startDate := dateOnlyUTC(start)
var endDate *time.Time
if pfk.ClosedAt != nil {
d := dateOnlyUTC(*pfk.ClosedAt)
endDate = &d
}
return &startDate, endDate, nil
}
var minCreated time.Time
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MIN(created_at)").
Where("project_flock_id = ?", projectFlockID).
Scan(&minCreated).Error; err != nil {
return nil, nil, err
}
var minChickin *time.Time
if err := db.Table("project_chickins pc").
Select("MIN(pc.chick_in_date)").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id").
Where("pfk.project_flock_id = ?", projectFlockID).
Scan(&minChickin).Error; err != nil {
return nil, nil, err
}
start := minCreated
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
}
startDate := dateOnlyUTC(start)
var endDate *time.Time
var openCount int64
if err := db.Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ? AND closed_at IS NULL", projectFlockID).
Count(&openCount).Error; err != nil {
return nil, nil, err
}
if openCount == 0 {
var maxClosed *time.Time
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MAX(closed_at)").
Where("project_flock_id = ?", projectFlockID).
Scan(&maxClosed).Error; err != nil {
return nil, nil, err
}
if maxClosed != nil && !maxClosed.IsZero() {
d := dateOnlyUTC(*maxClosed)
endDate = &d
}
}
return &startDate, endDate, nil
}
func dateOnlyUTC(t time.Time) time.Time {
u := t.UTC()
return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
}
func formatQuantity(qty float64, uom string) string {
qtyStr := strconv.FormatFloat(qty, 'f', -1, 64)
if uom == "" {
@@ -616,38 +722,17 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
return nil, err
}
realizations, err = s.allocateFarmOverheadRealizations(c.Context(), projectFlockID, projectFlockKandangID, realizations)
if err != nil {
return nil, err
}
projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, err
}
totalKandangCount := len(projectFlockKandangs)
// Build kandang count map for farm expense division
projectFlockKandangCountMap := make(map[uint]int)
projectFlockKandangCountMap[projectFlockID] = totalKandangCount
involvedProjectFlocks := make(map[uint]bool)
for _, realization := range realizations {
if realization.ExpenseNonstock != nil &&
realization.ExpenseNonstock.Expense != nil &&
realization.ExpenseNonstock.Expense.ProjectFlockId != nil {
var projectFlockIDs []uint
if err := json.Unmarshal([]byte(*realization.ExpenseNonstock.Expense.ProjectFlockId), &projectFlockIDs); err == nil {
for _, pfID := range projectFlockIDs {
if pfID != projectFlockID {
involvedProjectFlocks[pfID] = true
}
}
}
}
}
for pfID := range involvedProjectFlocks {
if pfKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), pfID); err == nil {
projectFlockKandangCountMap[pfID] = len(pfKandangs)
}
}
chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, err
@@ -688,11 +773,197 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
totalActualPopulation := totalChickinQty - totalDepletion
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount, projectFlockKandangCountMap)
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount)
return &result, nil
}
type activeKandangMetricRow struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProjectFlockID uint `gorm:"column:project_flock_id"`
KandangID uint `gorm:"column:kandang_id"`
Category string `gorm:"column:category"`
ChickinQty float64 `gorm:"column:chickin_qty"`
DepletionQty float64 `gorm:"column:depletion_qty"`
EggQty float64 `gorm:"column:egg_qty"`
}
func (s closingService) getActiveKandangMetrics(ctx context.Context, locationID uint, transactionDate time.Time) ([]activeKandangMetric, error) {
db := s.Repository.DB().WithContext(ctx)
rows := []activeKandangMetricRow{}
rawSQL := `
SELECT
pfk.id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pfk.kandang_id AS kandang_id,
pf.category AS category,
COALESCE((
SELECT SUM(pc.usage_qty)
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = pfk.id
AND pc.chick_in_date::date <= ?
), 0) AS chickin_qty,
COALESCE((
SELECT SUM(rd.qty)
FROM recording_depletions rd
JOIN recordings r ON r.id = rd.recording_id
WHERE r.project_flock_kandangs_id = pfk.id
AND r.record_datetime::date <= ?
), 0) AS depletion_qty,
COALESCE((
SELECT SUM(re.qty)
FROM recording_eggs re
JOIN recordings r2 ON r2.id = re.recording_id
WHERE r2.project_flock_kandangs_id = pfk.id
AND r2.record_datetime::date <= ?
), 0) AS egg_qty
FROM project_flock_kandangs pfk
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE pf.location_id = ?
AND (pfk.closed_at IS NULL OR pfk.closed_at::date > ?)
AND EXISTS (
SELECT 1
FROM project_chickins pc2
WHERE pc2.project_flock_kandang_id = pfk.id
AND pc2.chick_in_date::date <= ?
)
`
if err := db.Raw(rawSQL, transactionDate, transactionDate, transactionDate, locationID, transactionDate, transactionDate).Scan(&rows).Error; err != nil {
return nil, err
}
result := make([]activeKandangMetric, 0, len(rows))
for _, row := range rows {
metric := 0.0
switch strings.ToLower(strings.TrimSpace(row.Category)) {
case "growing":
metric = row.ChickinQty
case "laying":
metric = row.EggQty
default:
s.Log.Warnf("Unknown project flock category for overhead allocation: %s (pfk=%d)", row.Category, row.ProjectFlockKandangID)
}
result = append(result, activeKandangMetric{
ProjectFlockKandangID: row.ProjectFlockKandangID,
ProjectFlockID: row.ProjectFlockID,
KandangID: row.KandangID,
Category: row.Category,
Metric: metric,
})
}
return result, nil
}
func round2(value float64) float64 {
return math.Round(value*100) / 100
}
func allocateFarmLevelQty(totalQty float64, metrics []activeKandangMetric) map[uint]float64 {
allocations := make(map[uint]float64, len(metrics))
if totalQty == 0 || len(metrics) == 0 {
return allocations
}
totalMetric := 0.0
var maxMetric float64
var maxMetricID uint
for _, m := range metrics {
if m.Metric <= 0 {
continue
}
totalMetric += m.Metric
if m.Metric > maxMetric || maxMetricID == 0 {
maxMetric = m.Metric
maxMetricID = m.ProjectFlockKandangID
}
}
if totalMetric == 0 {
return allocations
}
sumRounded := 0.0
for _, m := range metrics {
if m.Metric <= 0 {
continue
}
portion := totalQty * (m.Metric / totalMetric)
rounded := round2(portion)
allocations[m.ProjectFlockKandangID] = rounded
sumRounded += rounded
}
diff := totalQty - sumRounded
if maxMetricID != 0 && diff != 0 {
allocations[maxMetricID] = round2(allocations[maxMetricID] + diff)
}
return allocations
}
func (s closingService) allocateFarmOverheadRealizations(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, realizations []entity.ExpenseRealization) ([]entity.ExpenseRealization, error) {
if len(realizations) == 0 {
return realizations, nil
}
cache := make(map[string][]activeKandangMetric)
allocated := make([]entity.ExpenseRealization, 0, len(realizations))
for _, realization := range realizations {
expenseNonstock := realization.ExpenseNonstock
if expenseNonstock == nil || expenseNonstock.Expense == nil {
allocated = append(allocated, realization)
continue
}
// If already bound to a specific project flock kandang, don't re-allocate.
if expenseNonstock.ProjectFlockKandangId != nil {
allocated = append(allocated, realization)
continue
}
expense := expenseNonstock.Expense
locationID := uint(expense.LocationId)
txDate := expense.RealizationDate
cacheKey := fmt.Sprintf("%d|%s", locationID, txDate.Format("2006-01-02"))
metrics, exists := cache[cacheKey]
if !exists {
var err error
metrics, err = s.getActiveKandangMetrics(ctx, locationID, txDate)
if err != nil {
return nil, err
}
cache[cacheKey] = metrics
}
allocations := allocateFarmLevelQty(realization.Qty, metrics)
allocatedQty := 0.0
if projectFlockKandangID != nil {
allocatedQty = allocations[*projectFlockKandangID]
} else {
for _, m := range metrics {
if m.ProjectFlockID == projectFlockID {
allocatedQty += allocations[m.ProjectFlockKandangID]
}
}
allocatedQty = round2(allocatedQty)
}
adj := realization
adj.Qty = allocatedQty
if adj.Qty == 0 {
adj.Price = realization.Price
}
allocated = append(allocated, adj)
}
return allocated, nil
}
func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) {
if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
@@ -885,7 +1156,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
chickenDepletion = 0
}
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age)
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age)
if fcrActFromRecording != nil {
chickenPerformance.FcrAct = *fcrActFromRecording
}
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -123,7 +124,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
continue
}
// We no longer filter by date for closing sapronak report; pass nil pointers.
// Filter sapronak data by project flock period range.
items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag)
if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err)
@@ -379,33 +380,33 @@ func buildSapronakDetails(
}
func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) {
// For sapronak closing report we intentionally ignore date range
// and aggregate all historical transactions for the kandang/project.
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId)
// Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any).
startDate, endDate := sapronakPeriodRange(pfk)
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.Id, pfk.KandangId, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId)
incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.Id, pfk.KandangId, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id)
usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id)
chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id)
usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id)
chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id)
usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
@@ -413,15 +414,15 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
usageDetailsRows = usageAllocatedDetails
chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{}
}
adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId)
adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId)
transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id)
salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
@@ -470,6 +471,7 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
// should not be counted yet. Only when category is LAYING we allow
// pullet usage to contribute to qty_used.
isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying))
hasChickin := len(pfk.Chickins) > 0
if !isLaying {
filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows))
@@ -491,11 +493,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
chickinUsageDetailsRows = filteredDetail
}
allUsageRows := append(usageRows, chickinUsageRows...)
incoming, usage := mapIncomingUsage(incomingRows, allUsageRows)
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
groupMap := make(map[string]*dto.SapronakGroupDTO)
for pid, rows := range chickinUsageDetailsRows {
if len(rows) == 0 {
continue
@@ -512,6 +509,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
transOutgoing := detailMaps.TransferOut
salesOutgoing := detailMaps.SalesOut
allUsageRows := append(usageRows, chickinUsageRows...)
incoming, usage := mapIncomingUsage(incomingRows, allUsageRows)
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
groupMap := make(map[string]*dto.SapronakGroupDTO)
transIncoming = dedupTransfers(transIncoming)
transOutgoing = dedupTransfers(transOutgoing)
@@ -775,6 +777,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if !matchesFlag(flag) {
continue
}
if hasChickin && (strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER")) {
continue
}
group := ensureGroup(flag)
for _, d := range details {
if d.Flag == "" {
@@ -794,6 +799,10 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if !matchesFlag(flag) {
continue
}
// For chicken, we don't count sales as sapronak outflow.
if strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER") {
continue
}
group := ensureGroup(flag)
for _, d := range details {
if d.Flag == "" {
@@ -815,3 +824,20 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
return items, groups, totalIncoming, totalUsage, nil
}
func sapronakPeriodRange(pfk entity.ProjectFlockKandang) (*time.Time, *time.Time) {
if len(pfk.Chickins) == 0 {
start := dateOnlyUTC(pfk.CreatedAt)
return &start, pfk.ClosedAt
}
minDate := pfk.Chickins[0].ChickInDate
for _, c := range pfk.Chickins[1:] {
if c.ChickInDate.Before(minDate) {
minDate = c.ChickInDate
}
}
start := dateOnlyUTC(minDate)
return &start, pfk.ClosedAt
}
@@ -64,9 +64,11 @@ func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error {
status = *item.Status
}
var kandang *kandangDTO.KandangRelationDTO
// var kandang *kandangDTO.KandangRelationDTO
var kandang *kandangDTO.KandangGroupRelationDTO
if item.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(item.Kandang)
// mapped := kandangDTO.ToKandangRelationDTO(item.Kandang)x
mapped := kandangDTO.ToKandangGroupRelationDTO(item.Kandang)
kandang = &mapped
}
@@ -19,19 +19,19 @@ type DailyChecklistRelationDTO struct {
}
type DailyChecklistListDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Category string `json:"category"`
Date time.Time `json:"date"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TotalPhase int `json:"total_phase"`
TotalActivity int `json:"total_activity"`
Progress int `json:"progress"`
RejectReason *string `json:"reject_reason"`
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Category string `json:"category"`
Date time.Time `json:"date"`
Kandang *kandangDTO.KandangGroupRelationDTO `json:"kandang,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TotalPhase int `json:"total_phase"`
TotalActivity int `json:"total_activity"`
Progress int `json:"progress"`
RejectReason *string `json:"reject_reason"`
}
type DailyChecklistDetailDTO struct {
@@ -156,9 +156,9 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO {
status = *e.Status
}
var kandang *kandangDTO.KandangRelationDTO
var kandang *kandangDTO.KandangGroupRelationDTO
if e.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(e.Kandang)
mapped := kandangDTO.ToKandangGroupRelationDTO(e.Kandang)
kandang = &mapped
}
@@ -75,7 +75,7 @@ type DailyChecklistListItem struct {
RejectReason *string
CreatedAt time.Time
UpdatedAt time.Time
Kandang entity.Kandang
Kandang entity.KandangGroup
TotalPhase int
TotalActivity int
Progress int
@@ -142,7 +142,7 @@ func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID u
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id = ?", checklistID)
@@ -168,7 +168,7 @@ func (s dailyChecklistService) ensureKandangAccess(c *fiber.Ctx, kandangID uint)
}
db := s.Repository.DB().WithContext(c.Context()).
Table("kandangs k").
Table("kandang_groups k").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("k.id = ?", kandangID)
@@ -196,7 +196,7 @@ func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_tasks t").
Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("t.id = ?", taskID)
@@ -225,7 +225,7 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id")
@@ -261,8 +261,11 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
if params.Search != "" {
re := regexp.MustCompile("[^a-zA-Z0-9]")
like := re.ReplaceAll([]byte("%"+params.Search+"%"), []byte(""))
db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", string(like), string(like))
normalizedSearch := re.ReplaceAllString(params.Search, "")
if normalizedSearch != "" {
like := "%" + normalizedSearch + "%"
db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", like, like)
}
}
countDB := db.Session(&gorm.Session{})
@@ -341,9 +344,9 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
}
}
kandangMap := make(map[uint]entity.Kandang)
kandangMap := make(map[uint]entity.KandangGroup)
if len(kandangIDs) > 0 {
var kandangs []entity.Kandang
var kandangs []entity.KandangGroup
if err := s.Repository.DB().WithContext(c.Context()).
Where("id IN ?", kandangIDs).
Preload("Location").
@@ -504,24 +507,66 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
status := req.Status
category := req.Category
targetID := uint(0)
createBody := &entity.DailyChecklist{
KandangId: req.KandangId,
Date: date,
Category: category,
Status: &status,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
existing := new(entity.DailyChecklist)
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
Take(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
err = s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}, {Name: "kandang_id"}, {Name: "category"}},
DoUpdates: clause.Assignments(map[string]any{"updated_at": time.Now()}),
}).Create(createBody).Error
if err == nil {
if err := tx.Model(&entity.DailyChecklist{}).
Where("id = ?", existing.Id).
Update("updated_at", time.Now()).Error; err != nil {
return err
}
targetID = existing.Id
return nil
}
createStatus := status
var rejectedCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND status = ?", date, req.KandangId, category, "REJECTED").
Count(&rejectedCount).Error; err != nil {
return err
}
if rejectedCount > 0 {
createStatus = "DRAFT"
}
createBody := &entity.DailyChecklist{
KandangId: req.KandangId,
Date: date,
Category: category,
Status: &createStatus,
}
if err := tx.Create(createBody).Error; err != nil {
// Handle concurrent insert for active checklist with same key.
if findErr := tx.
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
Take(existing).Error; findErr == nil {
targetID = existing.Id
return nil
}
return err
}
targetID = createBody.Id
return nil
})
if err != nil {
s.Log.Errorf("Failed to upsert dailyChecklist: %+v", err)
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
return s.GetOne(c, targetID)
}
func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) {
@@ -1019,7 +1064,7 @@ func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.Summa
MAX(a.updated_at) AS last_activity`).
Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id").
Joins("JOIN daily_checklists d ON d.id = t.checklist_id").
Joins("JOIN kandangs k ON k.id = d.kandang_id").
Joins("JOIN kandang_groups k ON k.id = d.kandang_id").
Joins("JOIN employees e ON e.id = a.employee_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas ar ON ar.id = loc.area_id").
@@ -1092,7 +1137,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id").
Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id").
Joins("JOIN employees e ON e.id = dca.employee_id").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Joins("JOIN phases p ON p.id = dcat.phase_id").
@@ -69,23 +69,19 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex
Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id").
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id").
Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id").
Where("expenses.realization_date IS NOT NULL").
Where("expenses.category = ?", "BOP")
if projectFlockKandangID != nil {
db = db.Where(`(
expense_nonstocks.project_flock_kandang_id = ? OR
(expense_nonstocks.kandang_id = (SELECT kandang_id FROM project_flock_kandangs WHERE id = ?) AND
expense_nonstocks.project_flock_kandang_id IS NULL) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, *projectFlockKandangID, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID))
)`, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID))
} else {
db = db.Where(`(
project_flock_kandangs.project_flock_id = ? OR
kandangs.id IN (SELECT kandang_id FROM project_flock_kandangs WHERE project_flock_id = ?) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, projectFlockID, projectFlockID, fmt.Sprintf("[%d]", projectFlockID))
)`, projectFlockID, fmt.Sprintf("[%d]", projectFlockID))
}
err := db.Find(&realizations).Error
@@ -5,6 +5,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
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"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
@@ -14,6 +15,7 @@ type ProductRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
SKU string `json:"sku"`
Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"`
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
}
@@ -89,11 +91,17 @@ func ToProductRelationDTO(e *entity.Product) *ProductRelationDTO {
mapped := productCategoryDTO.ToProductCategoryRelationDTO(e.ProductCategory)
category = &mapped
}
var uom *uomDTO.UomRelationDTO
if e.Uom.Id != 0 {
mapped := uomDTO.ToUomRelationDTO(e.Uom)
uom = &mapped
}
return &ProductRelationDTO{
Id: e.Id,
Name: e.Name,
SKU: sku,
Uom: uom,
ProductCategory: category,
}
}
@@ -3,6 +3,7 @@ package controller
import (
"math"
"strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services"
@@ -27,10 +28,13 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
ProductId: uint(c.QueryInt("product_id", 0)),
WarehouseId: uint(c.QueryInt("warehouse_id", 0)),
LocationId: uint(c.QueryInt("location_id", 0)),
Flags: c.Query("flags", ""),
KandangId: uint(c.QueryInt("kandang_id", 0)),
AvailableOnly: parseBoolQuery(c.Query("available_only", "")),
TransferContext: c.Query(utils.TransferContextKey, ""),
StockMode: c.Query("stock_mode", ""),
Type: c.Query("type", ""),
@@ -60,6 +64,15 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
})
}
func parseBoolQuery(raw string) bool {
switch strings.TrimSpace(strings.ToLower(raw)) {
case "1", "true", "yes", "y":
return true
default:
return false
}
}
func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
@@ -0,0 +1,70 @@
package controller
import (
"errors"
"net/http/httptest"
"testing"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations"
"gorm.io/gorm"
)
type stubProductWarehouseService struct {
lastQuery *validation.Query
}
func (s *stubProductWarehouseService) GetAll(_ *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
s.lastQuery = params
return []entity.ProductWarehouse{}, 0, nil
}
func (s *stubProductWarehouseService) GetOne(_ *fiber.Ctx, _ uint) (*entity.ProductWarehouse, error) {
return nil, gorm.ErrRecordNotFound
}
var _ service.ProductWarehouseService = (*stubProductWarehouseService)(nil)
func TestGetAllParsesLocationID(t *testing.T) {
app := fiber.New()
stub := &stubProductWarehouseService{}
ctrl := NewProductWarehouseController(stub)
app.Get("/product-warehouses", ctrl.GetAll)
req := httptest.NewRequest("GET", "/product-warehouses?location_id=16&kandang_id=59&limit=25&search=tektrol&available_only=true", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
if stub.lastQuery == nil {
t.Fatalf("expected service to receive query")
}
if stub.lastQuery.LocationId != 16 {
t.Fatalf("expected location_id 16, got %d", stub.lastQuery.LocationId)
}
if stub.lastQuery.KandangId != 59 {
t.Fatalf("expected kandang_id 59, got %d", stub.lastQuery.KandangId)
}
if stub.lastQuery.Limit != 25 {
t.Fatalf("expected limit 25, got %d", stub.lastQuery.Limit)
}
if stub.lastQuery.Search != "tektrol" {
t.Fatalf("expected search tektrol, got %s", stub.lastQuery.Search)
}
if !stub.lastQuery.AvailableOnly {
t.Fatalf("expected available_only true")
}
}
func TestStubImplementsServiceContract(t *testing.T) {
validate := validator.New()
if validate == nil {
t.Fatal(errors.New("validator should not be nil"))
}
}
@@ -7,6 +7,7 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
@@ -84,31 +85,28 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho
}
func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
err := r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId).
Order("id DESC").
Preload("ProjectFlockKandang").
First(&productWarehouse).Error
if err == nil {
if productWarehouse.ProjectFlockKandang.ClosedAt == nil {
return &productWarehouse, nil
}
}
err = r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId).
First(&productWarehouse).Error
warehouseIsKandang, err := r.isKandangWarehouse(ctx, warehouseId)
if err != nil {
return nil, err
}
return &productWarehouse, nil
if warehouseIsKandang {
if productWarehouse, err := r.findOpenKandangOwnedWarehouse(ctx, productId, warehouseId); err == nil {
return productWarehouse, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return r.findSharedWarehouse(ctx, productId, warehouseId)
}
if productWarehouse, err := r.findSharedWarehouse(ctx, productId, warehouseId); err == nil {
return productWarehouse, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return r.findOpenKandangOwnedWarehouse(ctx, productId, warehouseId)
}
func (r *ProductWarehouseRepositoryImpl) FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error) {
@@ -167,10 +165,42 @@ func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []s
return db
}
return db.
fallbackCategoryCodes := utils.LegacyProductCategoryCodesForFlags(flags)
db = db.
Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id").
Joins("JOIN flags f_flag ON f_flag.flagable_id = p_flag.id AND f_flag.flagable_type = ?", "products").
Where("f_flag.name IN ?", flags).
Joins("LEFT JOIN product_categories pc_flag ON pc_flag.id = p_flag.product_category_id")
actualFlagFilter := `
EXISTS (
SELECT 1
FROM flags f_flag
WHERE f_flag.flagable_id = p_flag.id
AND f_flag.flagable_type = ?
AND f_flag.name IN ?
)
`
if len(fallbackCategoryCodes) == 0 {
return db.Where(actualFlagFilter, entity.FlagableTypeProduct, flags).Distinct()
}
return db.
Where(
`(`+actualFlagFilter+`) OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_id = p_flag.id
AND f_any.flagable_type = ?
)
AND pc_flag.code IN ?
)`,
entity.FlagableTypeProduct,
flags,
entity.FlagableTypeProduct,
fallbackCategoryCodes,
).
Distinct()
}
@@ -266,18 +296,8 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse(
projectFlockKandangID *uint,
createdBy uint,
) (uint, error) {
record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID)
record, err := r.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID)
if err == nil {
// Backfill project_flock_kandang_id when it's missing and caller provides one.
if projectFlockKandangID != nil && (record.ProjectFlockKandangId == nil || *record.ProjectFlockKandangId == 0) {
if err := r.DB().WithContext(ctx).
Model(&entity.ProductWarehouse{}).
Where("id = ?", record.Id).
Update("project_flock_kandang_id", *projectFlockKandangID).Error; err != nil {
return 0, err
}
record.ProjectFlockKandangId = projectFlockKandangID
}
return record.Id, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -301,6 +321,45 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse(
return entity.Id, nil
}
func (r *ProductWarehouseRepositoryImpl) isKandangWarehouse(ctx context.Context, warehouseID uint) (bool, error) {
var kandangID *uint
if err := r.DB().WithContext(ctx).
Table("warehouses").
Select("kandang_id").
Where("id = ?", warehouseID).
Scan(&kandangID).Error; err != nil {
return false, err
}
return kandangID != nil && *kandangID != 0, nil
}
func (r *ProductWarehouseRepositoryImpl) findOpenKandangOwnedWarehouse(ctx context.Context, productID uint, warehouseID uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
err := r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productID, warehouseID).
Order("id DESC").
Preload("ProjectFlockKandang").
First(&productWarehouse).Error
if err != nil {
return nil, err
}
if productWarehouse.ProjectFlockKandang != nil && productWarehouse.ProjectFlockKandang.ClosedAt == nil {
return &productWarehouse, nil
}
return nil, gorm.ErrRecordNotFound
}
func (r *ProductWarehouseRepositoryImpl) findSharedWarehouse(ctx context.Context, productID uint, warehouseID uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
if err := r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productID, warehouseID).
Preload("ProjectFlockKandang").
First(&productWarehouse).Error; err != nil {
return nil, err
}
return &productWarehouse, nil
}
func (r *ProductWarehouseRepositoryImpl) GetByProductWarehouseAndProjectFlockKandang(
ctx context.Context,
productId uint,
@@ -0,0 +1,190 @@
package repository
import (
"context"
"testing"
"github.com/glebarez/sqlite"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
func TestGetProductWarehouseByProductAndWarehouseIDPrefersSharedForFarmWarehouse(t *testing.T) {
db := setupProductWarehouseRepoTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
insertProductWarehouseTestFixtures(t, db)
got, err := repo.GetProductWarehouseByProductAndWarehouseID(ctx, 1, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Id != 2 {
t.Fatalf("expected shared farm warehouse id 2, got %d", got.Id)
}
}
func TestGetProductWarehouseByProductAndWarehouseIDPrefersOpenKandangOwnedForKandangWarehouse(t *testing.T) {
db := setupProductWarehouseRepoTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
insertProductWarehouseTestFixtures(t, db)
got, err := repo.GetProductWarehouseByProductAndWarehouseID(ctx, 1, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Id != 3 {
t.Fatalf("expected kandang-owned warehouse id 3, got %d", got.Id)
}
}
func TestEnsureProductWarehouseDoesNotBackfillSharedWarehouse(t *testing.T) {
db := setupProductWarehouseRepoTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
insertProductWarehouseTestFixtures(t, db)
projectFlockKandangID := uint(101)
createdID, err := repo.EnsureProductWarehouse(ctx, 1, 1, &projectFlockKandangID, 9)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if createdID == 2 {
t.Fatalf("expected new kandang-attributed row instead of reusing shared row")
}
var sharedPfkID *uint
if err := db.WithContext(ctx).
Table("product_warehouses").
Select("project_flock_kandang_id").
Where("id = ?", 2).
Scan(&sharedPfkID).Error; err != nil {
t.Fatalf("failed to load shared warehouse row: %v", err)
}
if sharedPfkID != nil {
t.Fatalf("expected shared row attribution to stay nil, got %v", *sharedPfkID)
}
}
func setupProductWarehouseRepoTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE warehouses (id INTEGER PRIMARY KEY, kandang_id INTEGER NULL)`,
`CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, closed_at TIMESTAMP NULL)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL,
qty NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
func insertProductWarehouseTestFixtures(t *testing.T, db *gorm.DB) {
t.Helper()
statements := []string{
`INSERT INTO warehouses (id, kandang_id) VALUES (1, NULL), (2, 7)`,
`INSERT INTO project_flock_kandangs (id, closed_at) VALUES (101, NULL)`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES
(1, 1, 1, 101, 10),
(2, 1, 1, NULL, 20),
(3, 1, 2, 101, 30),
(4, 1, 2, NULL, 40)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding fixtures: %v", err)
}
}
}
func TestApplyFlagsFilterIncludesLegacyCategoryFallback(t *testing.T) {
db := setupProductWarehouseFlagFilterTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
var ids []uint
err := repo.ApplyFlagsFilter(
db.WithContext(ctx).Model(&entity.ProductWarehouse{}),
[]string{"PAKAN"},
).Order("product_warehouses.id").Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("expected flagged and legacy RAW rows to match, got %v", ids)
}
}
func TestApplyFlagsFilterDoesNotFallbackWhenProductAlreadyHasDifferentFlags(t *testing.T) {
db := setupProductWarehouseFlagFilterTestDB(t)
repo := NewProductWarehouseRepository(db)
ctx := context.Background()
var ids []uint
err := repo.ApplyFlagsFilter(
db.WithContext(ctx).Model(&entity.ProductWarehouse{}),
[]string{"PAKAN"},
).Where("product_warehouses.id = ?", 3).Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ids) != 0 {
t.Fatalf("expected OVK-flagged product not to match PAKAN fallback, got %v", ids)
}
}
func setupProductWarehouseFlagFilterTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE product_categories (id INTEGER PRIMARY KEY, code TEXT NOT NULL)`,
`CREATE TABLE products (id INTEGER PRIMARY KEY, product_category_id INTEGER NOT NULL)`,
`CREATE TABLE flags (id INTEGER PRIMARY KEY, flagable_id INTEGER NOT NULL, flagable_type TEXT NOT NULL, name TEXT NOT NULL)`,
`CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL, warehouse_id INTEGER NOT NULL, project_flock_kandang_id INTEGER NULL, qty NUMERIC(15,3) NOT NULL DEFAULT 0)`,
`INSERT INTO product_categories (id, code) VALUES (1, 'STR'), (2, 'RAW'), (3, 'OBT')`,
`INSERT INTO products (id, product_category_id) VALUES (10, 1), (20, 2), (30, 2), (40, 3)`,
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES
(1, 10, 'products', 'PAKAN'),
(2, 10, 'products', 'STARTER'),
(3, 40, 'products', 'OVK'),
(4, 40, 'products', 'OBAT')`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES
(1, 10, 1, NULL, 10),
(2, 20, 1, NULL, 20),
(3, 40, 1, NULL, 30)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -53,6 +53,31 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.Chickins")
}
func applyWarehouseSelectionFilter(db *gorm.DB, kandangID, locationID uint) *gorm.DB {
switch {
case kandangID != 0 && locationID != 0:
return db.Where(
"w_scope.location_id = ? AND (w_scope.type = ? OR w_scope.kandang_id = ?)",
locationID,
"LOKASI",
kandangID,
)
case kandangID != 0:
return db.Where("w_scope.kandang_id = ?", kandangID)
case locationID != 0:
return db.Where("w_scope.location_id = ?", locationID)
default:
return db
}
}
func applyAvailableOnlyFilter(db *gorm.DB, availableOnly bool) *gorm.DB {
if !availableOnly {
return db
}
return db.Where("COALESCE(product_warehouses.qty, 0) > 0")
}
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -133,15 +158,31 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("product_id = ?", params.ProductId)
}
if params.KandangId != 0 {
db = db.Joins("JOIN warehouses ON product_warehouses.warehouse_id = warehouses.id").
Where("warehouses.kandang_id = ?", params.KandangId)
}
db = applyAvailableOnlyFilter(db, params.AvailableOnly)
db = applyWarehouseSelectionFilter(db, params.KandangId, params.LocationId)
if params.WarehouseId != 0 {
db = db.Where("warehouse_id = ?", params.WarehouseId)
}
if strings.TrimSpace(params.Search) != "" {
searchPattern := "%" + strings.TrimSpace(params.Search) + "%"
db = db.Where(
`(
EXISTS (
SELECT 1
FROM products p_search
WHERE p_search.id = product_warehouses.product_id
AND p_search.name ILIKE ?
)
OR w_scope.name ILIKE ?
)`,
searchPattern,
searchPattern,
)
}
if len(marketingTypes) > 0 {
flagSet := make(map[string]struct{})
for _, t := range marketingTypes {
@@ -0,0 +1,126 @@
package service
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestApplyWarehouseSelectionFilterIncludesFarmAndSelectedKandangInLocation(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 11, 101).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1, 2})
}
func TestApplyWarehouseSelectionFilterPreservesKandangOnlyBehavior(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 11, 0).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1})
}
func TestApplyWarehouseSelectionFilterSupportsLocationOnlyQuery(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 0, 101).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1, 2, 3})
}
func TestApplyAvailableOnlyFilterRemovesZeroQtyRows(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyAvailableOnlyFilter(baseProductWarehouseSelectionQuery(db), true).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1, 2, 4})
}
func setupProductWarehouseServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
location_id INTEGER NULL,
kandang_id INTEGER NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
warehouse_id INTEGER NOT NULL,
qty NUMERIC NULL
)`,
`INSERT INTO warehouses (id, type, location_id, kandang_id, deleted_at) VALUES
(1, 'KANDANG', 101, 11, NULL),
(2, 'LOKASI', 101, NULL, NULL),
(3, 'KANDANG', 101, 12, NULL),
(4, 'LOKASI', 102, NULL, NULL)`,
`INSERT INTO product_warehouses (id, warehouse_id, qty) VALUES
(1, 1, 10),
(2, 2, 20),
(3, 3, 0),
(4, 4, 15)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
func baseProductWarehouseSelectionQuery(db *gorm.DB) *gorm.DB {
return db.Table("product_warehouses").
Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id").
Where("w_scope.deleted_at IS NULL")
}
func assertUintIDs(t *testing.T, got []uint, want []uint) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("expected ids %v, got %v", want, got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("expected ids %v, got %v", want, got)
}
}
}
@@ -15,10 +15,13 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Search string `query:"search" validate:"omitempty"`
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"`
LocationId uint `query:"location_id" validate:"omitempty,number,min=1"`
Flags string `query:"flags" validate:"omitempty"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
AvailableOnly bool `query:"available_only"`
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"`
@@ -0,0 +1,548 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/gofiber/fiber/v2"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/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/fifo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *transferService) CreateSystemTransfer(ctx context.Context, req *SystemTransferRequest) (*entity.StockTransfer, error) {
if req == nil {
return nil, fmt.Errorf("system transfer request is required")
}
if strings.TrimSpace(req.TransferReason) == "" {
return nil, fmt.Errorf("transfer reason is required")
}
if req.TransferDate.IsZero() {
return nil, fmt.Errorf("transfer date is required")
}
if req.SourceWarehouseID == 0 || req.DestinationWarehouseID == 0 {
return nil, fmt.Errorf("source and destination warehouse are required")
}
if req.SourceWarehouseID == req.DestinationWarehouseID {
return nil, fmt.Errorf("source and destination warehouse must be different")
}
if req.ActorID == 0 {
return nil, fmt.Errorf("actor id is required")
}
if err := s.validateTransferWarehousesAndProducts(ctx, req.SourceWarehouseID, req.DestinationWarehouseID, req.Products); err != nil {
return nil, err
}
var result *entity.StockTransfer
err := s.StockTransferRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
movementResult, err := s.createTransferMovement(ctx, tx, req)
if err != nil {
return err
}
result = movementResult.Transfer
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *transferService) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
if id == 0 {
return fmt.Errorf("transfer id is required")
}
if actorID == 0 {
return fmt.Errorf("actor id is required")
}
var deletedDetails []entity.StockTransferDetail
err := s.StockTransferRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
deletedDetails, err = s.deleteTransferCore(ctx, tx, uint64(id), actorID)
return err
})
if err != nil {
return err
}
if len(deletedDetails) > 0 && s.ExpenseBridge != nil {
if err := s.ExpenseBridge.OnItemsDeleted(ctx, uint64(id), deletedDetails); err != nil {
s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense")
}
}
return nil
}
func (s *transferService) validateTransferWarehousesAndProducts(
ctx context.Context,
sourceWarehouseID uint,
destinationWarehouseID uint,
products []SystemTransferProduct,
) error {
if len(products) == 0 {
return fmt.Errorf("transfer products are required")
}
pwIDs := make([]uint, 0, len(products))
for _, product := range products {
if product.ProductID == 0 {
return fmt.Errorf("product id is required")
}
if product.ProductQty <= 0 {
return fmt.Errorf("product qty must be greater than 0 for product %d", product.ProductID)
}
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
ctx, product.ProductID, sourceWarehouseID,
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, sourceWarehouseID))
}
s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, sourceWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
}
if sourcePW.Quantity < product.ProductQty {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty))
}
pwIDs = append(pwIDs, sourcePW.Id)
}
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(ctx, s.StockTransferRepo.DB(), pwIDs); err != nil {
return err
}
destPfkID, err := s.getActiveProjectFlockKandangID(ctx, destinationWarehouseID)
if err != nil {
return err
}
if destPfkID == 0 {
return nil
}
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(ctx, destPfkID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02")))
}
return nil
}
func (s *transferService) createTransferMovement(
ctx context.Context,
tx *gorm.DB,
req *SystemTransferRequest,
) (*transferMovementResult, error) {
if tx == nil {
return nil, fmt.Errorf("transaction is required")
}
stockTransferRepoTX := s.StockTransferRepo.WithTx(tx)
stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx)
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
stockLogsRepoTX := rStockLogs.NewStockLogRepository(tx)
movementNumber := strings.TrimSpace(req.MovementNumber)
if movementNumber == "" {
var err error
movementNumber, err = s.StockTransferRepo.GenerateMovementNumber(ctx)
if err != nil {
s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
}
}
entityTransfer := &entity.StockTransfer{
FromWarehouseId: uint64(req.SourceWarehouseID),
ToWarehouseId: uint64(req.DestinationWarehouseID),
Reason: req.TransferReason,
TransferDate: req.TransferDate,
MovementNumber: movementNumber,
CreatedBy: uint64(req.ActorID),
}
if err := stockTransferRepoTX.CreateOne(ctx, entityTransfer, nil); err != nil {
return nil, err
}
details := make([]*entity.StockTransferDetail, 0, len(req.Products))
detailMap := make(map[uint64]*entity.StockTransferDetail, len(req.Products))
for _, product := range req.Products {
sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
ctx, product.ProductID, req.SourceWarehouseID,
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
}
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
ctx, product.ProductID, req.DestinationWarehouseID,
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, req.DestinationWarehouseID)
if err != nil {
return nil, err
}
var pfkID *uint
if projectFlockKandangID > 0 {
pfkID = &projectFlockKandangID
}
destPW = &entity.ProductWarehouse{
ProductId: product.ProductID,
WarehouseId: req.DestinationWarehouseID,
Quantity: 0,
ProjectFlockKandangId: pfkID,
}
if err := productWarehouseRepoTX.CreateOne(ctx, destPW, nil); err != nil {
s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
}
}
detail := &entity.StockTransferDetail{
StockTransferId: entityTransfer.Id,
ProductId: uint64(product.ProductID),
SourceProductWarehouseID: func() *uint64 {
id := uint64(sourcePW.Id)
return &id
}(),
UsageQty: 0,
PendingQty: 0,
DestProductWarehouseID: func() *uint64 {
id := uint64(destPW.Id)
return &id
}(),
TotalQty: 0,
TotalUsed: 0,
}
details = append(details, detail)
detailMap[uint64(product.ProductID)] = detail
}
if err := stockTransferDetailRepoTX.CreateMany(ctx, details, nil); err != nil {
return nil, err
}
flagGroupByProduct := make(map[uint]string, len(req.Products))
for _, product := range req.Products {
detail := detailMap[uint64(product.ProductID)]
if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid")
}
flagGroupCode, ok := flagGroupByProduct[product.ProductID]
if !ok {
var err error
flagGroupCode, err = s.resolveTransferFlagGroup(ctx, tx, product.ProductID)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err))
}
flagGroupByProduct[product.ProductID] = flagGroupCode
}
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"usage_qty": product.ProductQty,
"pending_qty": 0,
"total_qty": product.ProductQty,
}).Error; err != nil {
s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
asOf := req.TransferDate
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err))
}
type usageSnapshot struct {
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
}
var usage usageSnapshot
if err := tx.WithContext(ctx).
Table("stock_transfer_details").
Select("usage_qty, pending_qty").
Where("id = ?", detail.Id).
Take(&usage).Error; err != nil {
s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking")
}
outUsageQty := usage.UsageQty
outPendingQty := usage.PendingQty
if outPendingQty > 1e-6 {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID))
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: req.ActorID,
Increase: 0,
Decrease: outUsageQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: req.StockLogNotes,
}
stockLogs, err := stockLogsRepoTX.GetByProductWarehouse(ctx, uint(*detail.SourceProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stockLogsRepoTX.CreateOne(ctx, stockLogDecrease, nil); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.DestProductWarehouseID),
CreatedBy: req.ActorID,
Increase: outUsageQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: req.StockLogNotes,
}
stockLogs, err = stockLogsRepoTX.GetByProductWarehouse(ctx, uint(*detail.DestProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stockLogsRepoTX.CreateOne(ctx, stockLogIncrease, nil); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
}
return &transferMovementResult{
Transfer: entityTransfer,
DetailByPID: detailMap,
}, nil
}
func (s *transferService) deleteTransferCore(
ctx context.Context,
tx *gorm.DB,
transferID uint64,
actorID uint,
) ([]entity.StockTransferDetail, error) {
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
var transfer entity.StockTransfer
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", transferID).
Where("deleted_at IS NULL").
Take(&transfer).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", transferID))
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
}
var details []entity.StockTransferDetail
if err := tx.WithContext(ctx).
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 nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer")
}
if len(details) == 0 {
return nil, 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(ctx, tx, detailIDs); err != nil {
return nil, 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 nil, 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 nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id))
}
flagGroupCode, err := s.resolveTransferFlagGroup(ctx, tx, uint(detail.ProductId))
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err))
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, 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 nil, 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 nil, 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(
ctx,
stockLogRepoTx,
uint(*detail.SourceProductWarehouseID),
actorID,
releasedQty,
0,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return nil, err
}
}
destDecreaseQty := detail.TotalQty
if destDecreaseQty <= 1e-6 {
destDecreaseQty = detail.UsageQty
}
if destDecreaseQty > 1e-6 {
if err := s.appendStockLog(
ctx,
stockLogRepoTx,
uint(*detail.DestProductWarehouseID),
actorID,
0,
destDecreaseQty,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return nil, err
}
}
destReflows[reflowKey{
flagGroupCode: flagGroupCode,
productWarehouseID: uint(*detail.DestProductWarehouseID),
}] = struct{}{}
}
now := time.Now().UTC()
if err := tx.WithContext(ctx).
Where("stock_transfer_detail_id IN ?", detailIDs).
Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer")
}
if err := tx.WithContext(ctx).
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 nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer")
}
if err := tx.WithContext(ctx).
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 nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer")
}
asOf := transfer.TransferDate
for key := range destReflows {
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: key.flagGroupCode,
ProductWarehouseID: key.productWarehouseID,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err))
}
}
if err := tx.WithContext(ctx).
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 nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
}
return details, nil
}
@@ -0,0 +1,481 @@
package service
import (
"context"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/go-playground/validator/v10"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
func TestCreateSystemTransferCreatesAuditableMovement(t *testing.T) {
db := setupSystemTransferTestDB(t)
svc, fifoStub := newSystemTransferTestService(t, db)
transferDate := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC)
result, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{
TransferReason: "EGG_FARM_CUTOVER|run_id=test-1|location=Jamali|cutover_date=2026-04-07",
TransferDate: transferDate,
SourceWarehouseID: 1,
DestinationWarehouseID: 2,
Products: []SystemTransferProduct{
{ProductID: 8, ProductQty: 50},
},
ActorID: 99,
MovementNumber: "PND-LTI-TEST-0001",
StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-1|location=Jamali|cutover_date=2026-04-07",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected transfer result")
}
if result.MovementNumber != "PND-LTI-TEST-0001" {
t.Fatalf("expected movement number to be preserved, got %s", result.MovementNumber)
}
var transfer entity.StockTransfer
if err := db.WithContext(context.Background()).First(&transfer, result.Id).Error; err != nil {
t.Fatalf("failed to load created transfer: %v", err)
}
var detail entity.StockTransferDetail
if err := db.WithContext(context.Background()).
Where("stock_transfer_id = ?", transfer.Id).
First(&detail).Error; err != nil {
t.Fatalf("failed to load transfer detail: %v", err)
}
if detail.UsageQty != 50 {
t.Fatalf("expected usage qty 50, got %v", detail.UsageQty)
}
if detail.TotalQty != 50 {
t.Fatalf("expected total qty 50, got %v", detail.TotalQty)
}
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID != 10 {
t.Fatalf("expected source product warehouse 10, got %+v", detail.SourceProductWarehouseID)
}
if detail.DestProductWarehouseID == nil {
t.Fatal("expected destination product warehouse to be created")
}
var destPW entity.ProductWarehouse
if err := db.WithContext(context.Background()).
First(&destPW, *detail.DestProductWarehouseID).Error; err != nil {
t.Fatalf("failed to load destination product warehouse: %v", err)
}
if destPW.WarehouseId != 2 {
t.Fatalf("expected destination warehouse id 2, got %d", destPW.WarehouseId)
}
if destPW.ProductId != 8 {
t.Fatalf("expected destination product id 8, got %d", destPW.ProductId)
}
if destPW.ProjectFlockKandangId != nil {
t.Fatalf("expected destination product warehouse to stay shared, got %+v", destPW.ProjectFlockKandangId)
}
var stockLogs []entity.StockLog
if err := db.WithContext(context.Background()).
Order("id ASC").
Find(&stockLogs).Error; err != nil {
t.Fatalf("failed to load stock logs: %v", err)
}
if len(stockLogs) != 3 {
t.Fatalf("expected 3 stock logs (seed + out + in), got %d", len(stockLogs))
}
if stockLogs[1].ProductWarehouseId != 10 || stockLogs[1].Decrease != 50 || stockLogs[1].Stock != 0 {
t.Fatalf("unexpected source stock log after transfer: %+v", stockLogs[1])
}
if stockLogs[2].ProductWarehouseId != destPW.Id || stockLogs[2].Increase != 50 || stockLogs[2].Stock != 50 {
t.Fatalf("unexpected destination stock log after transfer: %+v", stockLogs[2])
}
if len(fifoStub.reflowCalls) != 2 {
t.Fatalf("expected 2 reflow calls, got %d", len(fifoStub.reflowCalls))
}
if fifoStub.reflowCalls[0].ProductWarehouseID != 10 {
t.Fatalf("expected first reflow on source pw 10, got %d", fifoStub.reflowCalls[0].ProductWarehouseID)
}
if fifoStub.reflowCalls[1].ProductWarehouseID != destPW.Id {
t.Fatalf("expected second reflow on destination pw %d, got %d", destPW.Id, fifoStub.reflowCalls[1].ProductWarehouseID)
}
}
func TestDeleteSystemTransferRollsBackTransferWhenUnused(t *testing.T) {
db := setupSystemTransferTestDB(t)
svc, fifoStub := newSystemTransferTestService(t, db)
created, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{
TransferReason: "EGG_FARM_CUTOVER|run_id=test-rollback|location=Jamali|cutover_date=2026-04-07",
TransferDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
SourceWarehouseID: 1,
DestinationWarehouseID: 2,
Products: []SystemTransferProduct{{ProductID: 8, ProductQty: 50}},
ActorID: 99,
MovementNumber: "PND-LTI-TEST-ROLLBACK",
StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-rollback|location=Jamali|cutover_date=2026-04-07",
})
if err != nil {
t.Fatalf("failed to create transfer: %v", err)
}
var detail entity.StockTransferDetail
if err := db.WithContext(context.Background()).
Where("stock_transfer_id = ?", created.Id).
First(&detail).Error; err != nil {
t.Fatalf("failed to load transfer detail: %v", err)
}
fifoStub.rollbackReleasedQty[detail.Id] = detail.UsageQty
if err := svc.DeleteSystemTransfer(context.Background(), uint(created.Id), 99); err != nil {
t.Fatalf("expected delete to succeed, got %v", err)
}
var deletedTransfer entity.StockTransfer
if err := db.WithContext(context.Background()).Unscoped().First(&deletedTransfer, created.Id).Error; err != nil {
t.Fatalf("failed to load deleted transfer: %v", err)
}
if deletedTransfer.DeletedAt == nil {
t.Fatal("expected transfer to be soft deleted")
}
var deletedDetail entity.StockTransferDetail
if err := db.WithContext(context.Background()).Unscoped().First(&deletedDetail, detail.Id).Error; err != nil {
t.Fatalf("failed to load deleted transfer detail: %v", err)
}
if deletedDetail.DeletedAt == nil {
t.Fatal("expected transfer detail to be soft deleted")
}
var stockLogs []entity.StockLog
if err := db.WithContext(context.Background()).
Order("id ASC").
Find(&stockLogs).Error; err != nil {
t.Fatalf("failed to load stock logs: %v", err)
}
if len(stockLogs) != 5 {
t.Fatalf("expected 5 stock logs (seed + create out/in + delete in/out), got %d", len(stockLogs))
}
if stockLogs[3].ProductWarehouseId != 10 || stockLogs[3].Increase != 50 || stockLogs[3].Stock != 50 {
t.Fatalf("unexpected rollback source stock log: %+v", stockLogs[3])
}
if stockLogs[4].Decrease != 50 || stockLogs[4].Stock != 0 {
t.Fatalf("unexpected rollback destination stock log: %+v", stockLogs[4])
}
if len(fifoStub.rollbackCalls) != 1 {
t.Fatalf("expected 1 rollback call, got %d", len(fifoStub.rollbackCalls))
}
if len(fifoStub.reflowCalls) != 3 {
t.Fatalf("expected 3 reflow calls (2 create + 1 delete), got %d", len(fifoStub.reflowCalls))
}
}
func TestDeleteSystemTransferRejectsRollbackWhenDownstreamConsumptionExists(t *testing.T) {
db := setupSystemTransferTestDB(t)
svc, fifoStub := newSystemTransferTestService(t, db)
created, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{
TransferReason: "EGG_FARM_CUTOVER|run_id=test-guard|location=Jamali|cutover_date=2026-04-07",
TransferDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
SourceWarehouseID: 1,
DestinationWarehouseID: 2,
Products: []SystemTransferProduct{{ProductID: 8, ProductQty: 50}},
ActorID: 99,
MovementNumber: "PND-LTI-TEST-GUARD",
StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-guard|location=Jamali|cutover_date=2026-04-07",
})
if err != nil {
t.Fatalf("failed to create transfer: %v", err)
}
var detail entity.StockTransferDetail
if err := db.WithContext(context.Background()).
Where("stock_transfer_id = ?", created.Id).
First(&detail).Error; err != nil {
t.Fatalf("failed to load transfer detail: %v", err)
}
if err := db.Exec(`
INSERT INTO stock_allocations (
id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty,
allocation_purpose, status, function_code, flag_group_code, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
`, 1, *detail.DestProductWarehouseID, fifo.StockableKeyStockTransferIn.String(), detail.Id, fifo.UsableKeyRecordingStock.String(), 9001, 10,
entity.StockAllocationPurposeConsume, entity.StockAllocationStatusActive, "RECORDING_STOCK_OUT", "EGG").Error; err != nil {
t.Fatalf("failed to seed stock allocation: %v", err)
}
err = svc.DeleteSystemTransfer(context.Background(), uint(created.Id), 99)
if err == nil {
t.Fatal("expected delete to be blocked by downstream consumption")
}
if !strings.Contains(err.Error(), "tidak dapat dihapus") {
t.Fatalf("expected downstream guard error, got %v", err)
}
if len(fifoStub.rollbackCalls) != 0 {
t.Fatalf("expected rollback not to be called, got %d calls", len(fifoStub.rollbackCalls))
}
var transfer entity.StockTransfer
if err := db.WithContext(context.Background()).First(&transfer, created.Id).Error; err != nil {
t.Fatalf("failed to reload transfer: %v", err)
}
if transfer.DeletedAt != nil {
t.Fatal("expected transfer to remain active after guard failure")
}
}
type fifoStockV2Stub struct {
reflowCalls []commonSvc.FifoStockV2ReflowRequest
rollbackCalls []commonSvc.FifoStockV2RollbackRequest
rollbackReleasedQty map[uint64]float64
}
func (f *fifoStockV2Stub) Gather(ctx context.Context, req commonSvc.FifoStockV2GatherRequest) ([]commonSvc.FifoStockV2GatherRow, error) {
return nil, nil
}
func (f *fifoStockV2Stub) Allocate(ctx context.Context, req commonSvc.FifoStockV2AllocateRequest) (*commonSvc.FifoStockV2AllocateResult, error) {
return nil, nil
}
func (f *fifoStockV2Stub) Rollback(ctx context.Context, req commonSvc.FifoStockV2RollbackRequest) (*commonSvc.FifoStockV2RollbackResult, error) {
f.rollbackCalls = append(f.rollbackCalls, req)
return &commonSvc.FifoStockV2RollbackResult{
ReleasedQty: f.rollbackReleasedQty[uint64(req.Usable.ID)],
}, nil
}
func (f *fifoStockV2Stub) Reflow(ctx context.Context, req commonSvc.FifoStockV2ReflowRequest) (*commonSvc.FifoStockV2ReflowResult, error) {
f.reflowCalls = append(f.reflowCalls, req)
return &commonSvc.FifoStockV2ReflowResult{}, nil
}
func (f *fifoStockV2Stub) Recalculate(ctx context.Context, req commonSvc.FifoStockV2RecalculateRequest) (*commonSvc.FifoStockV2RecalculateResult, error) {
return &commonSvc.FifoStockV2RecalculateResult{}, nil
}
func newSystemTransferTestService(t *testing.T, db *gorm.DB) (TransferService, *fifoStockV2Stub) {
t.Helper()
fifoStub := &fifoStockV2Stub{rollbackReleasedQty: make(map[uint64]float64)}
return NewTransferService(
validator.New(),
rTransfer.NewStockTransferRepository(db),
rTransfer.NewStockTransferDetailRepository(db),
rTransfer.NewStockTransferDeliveryRepository(db),
rTransfer.NewStockTransferDeliveryItemRepository(db),
rStockLogs.NewStockLogRepository(db),
rProductWarehouse.NewProductWarehouseRepository(db),
nil,
rWarehouse.NewWarehouseRepository(db),
nil,
nil,
nil,
fifoStub,
nil,
), fifoStub
}
func setupSystemTransferTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
area_id INTEGER NOT NULL DEFAULT 1,
location_id INTEGER NULL,
kandang_id INTEGER NULL,
created_by INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE product_categories (
id INTEGER PRIMARY KEY,
name TEXT NULL,
code TEXT NOT NULL,
created_by INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
brand TEXT NOT NULL DEFAULT '',
sku TEXT NULL,
uom_id INTEGER NOT NULL DEFAULT 1,
product_category_id INTEGER NULL,
product_price NUMERIC NOT NULL DEFAULT 0,
selling_price NUMERIC NULL,
tax NUMERIC NULL,
expiry_period INTEGER NULL,
created_by INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
is_visible BOOLEAN NOT NULL DEFAULT 1
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL,
qty NUMERIC NOT NULL DEFAULT 0
)`,
`CREATE TABLE flags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
flagable_id INTEGER NOT NULL,
flagable_type TEXT NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_flag_groups (
code TEXT PRIMARY KEY,
is_active BOOLEAN NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_flag_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flag_name TEXT NOT NULL,
flag_group_code TEXT NOT NULL,
is_active BOOLEAN NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_route_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lane TEXT NOT NULL,
function_code TEXT NOT NULL,
source_table TEXT NOT NULL,
flag_group_code TEXT NOT NULL,
legacy_type_key TEXT NULL,
allow_pending_default BOOLEAN NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_overconsume_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lane TEXT NOT NULL,
flag_group_code TEXT NULL,
function_code TEXT NULL,
allow_overconsume BOOLEAN NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE stock_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
movement_number TEXT NOT NULL,
from_warehouse_id INTEGER NOT NULL,
to_warehouse_id INTEGER NOT NULL,
transfer_date TIMESTAMP NOT NULL,
reason TEXT,
created_by INTEGER NOT NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_transfer_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
source_product_warehouse_id INTEGER NULL,
usage_qty NUMERIC NOT NULL DEFAULT 0,
pending_qty NUMERIC NOT NULL DEFAULT 0,
dest_product_warehouse_id INTEGER NULL,
total_qty NUMERIC NOT NULL DEFAULT 0,
total_used NUMERIC NOT NULL DEFAULT 0,
expense_nonstock_id INTEGER NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_transfer_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_transfer_id INTEGER NOT NULL,
supplier_id INTEGER NULL,
vehicle_plate TEXT NULL,
driver_name TEXT NULL,
shipping_cost_item NUMERIC NULL,
shipping_cost_total NUMERIC NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_transfer_delivery_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_transfer_delivery_id INTEGER NOT NULL,
stock_transfer_detail_id INTEGER NOT NULL,
quantity NUMERIC NOT NULL DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_warehouse_id INTEGER NOT NULL,
created_by INTEGER NOT NULL,
increase NUMERIC NOT NULL DEFAULT 0,
decrease NUMERIC NOT NULL DEFAULT 0,
stock NUMERIC NOT NULL DEFAULT 0,
loggable_type TEXT NOT NULL,
loggable_id INTEGER NOT NULL,
notes TEXT NULL,
created_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_warehouse_id INTEGER NOT NULL,
stockable_type TEXT NOT NULL,
stockable_id INTEGER NOT NULL,
usable_type TEXT NOT NULL,
usable_id INTEGER NOT NULL,
qty NUMERIC NOT NULL DEFAULT 0,
allocation_purpose TEXT NOT NULL,
status TEXT NOT NULL,
function_code TEXT NULL,
flag_group_code TEXT NULL,
deleted_at TIMESTAMP NULL
)`,
`INSERT INTO warehouses (id, name, type, area_id, location_id, kandang_id, created_by, created_at, updated_at, deleted_at) VALUES
(1, 'Gudang Kandang Legacy', 'LOKASI', 1, 16, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL),
(2, 'Gudang Farm Jamali', 'LOKASI', 1, 16, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO product_categories (id, name, code, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Egg', 'EGG', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO products (
id, name, brand, sku, uom_id, product_category_id, product_price, selling_price, tax,
expiry_period, created_by, created_at, updated_at, deleted_at, is_visible
) VALUES (
8, 'Telur Utuh', '', NULL, 1, 1, 0, NULL, NULL, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, 1
)`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES
(10, 8, 1, NULL, 50)`,
`INSERT INTO flags (name, flagable_id, flagable_type) VALUES ('TELUR', 8, 'products')`,
`INSERT INTO fifo_stock_v2_flag_groups (code, is_active) VALUES ('EGG', 1)`,
`INSERT INTO fifo_stock_v2_flag_members (flag_name, flag_group_code, is_active) VALUES ('TELUR', 'EGG', 1)`,
`INSERT INTO fifo_stock_v2_route_rules (lane, function_code, source_table, flag_group_code, legacy_type_key, allow_pending_default, is_active) VALUES
('USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'EGG', 'STOCK_TRANSFER_OUT', 0, 1)`,
`INSERT INTO stock_logs (id, product_warehouse_id, created_by, increase, decrease, stock, loggable_type, loggable_id, notes, created_at) VALUES
(1, 10, 1, 50, 0, 50, 'PURCHASE', 1, 'seed', CURRENT_TIMESTAMP)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing test schema: %v\nstatement: %s", err, stmt)
}
}
return db
}
@@ -26,7 +26,6 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type TransferService interface {
@@ -34,6 +33,8 @@ type TransferService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error)
CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
CreateSystemTransfer(ctx context.Context, req *SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
}
type transferService struct {
@@ -63,6 +64,27 @@ type downstreamDependency struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
type SystemTransferProduct struct {
ProductID uint
ProductQty float64
}
type SystemTransferRequest struct {
TransferReason string
TransferDate time.Time
SourceWarehouseID uint
DestinationWarehouseID uint
Products []SystemTransferProduct
ActorID uint
MovementNumber string
StockLogNotes string
}
type transferMovementResult struct {
Transfer *entity.StockTransfer
DetailByPID map[uint64]*entity.StockTransferDetail
}
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService {
return &transferService{
Log: utils.Log,
@@ -185,50 +207,17 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
}
func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) {
pwIDs := make([]uint, 0, len(req.Products))
products := make([]SystemTransferProduct, 0, len(req.Products))
for _, product := range req.Products {
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
}
if sourcePW.Quantity < product.ProductQty {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty))
}
pwIDs = append(pwIDs, sourcePW.Id)
products = append(products, SystemTransferProduct{
ProductID: uint(product.ProductID),
ProductQty: product.ProductQty,
})
}
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(
c.Context(),
s.StockTransferRepo.DB(),
pwIDs,
); err != nil {
if err := s.validateTransferWarehousesAndProducts(c.Context(), uint(req.SourceWarehouseID), uint(req.DestinationWarehouseID), products); err != nil {
return nil, err
}
destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID))
if err != nil {
return nil, err
}
if destPfkID > 0 {
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02")))
}
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
@@ -249,11 +238,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
for _, delivery := range req.Deliveries {
if delivery.SupplierID == 0 {
continue
}
if delivery.VehiclePlate == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Vehicle plate wajib diisi ketika supplier dipilih")
}
@@ -280,104 +267,28 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
}
movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context())
if err != nil {
s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
}
transferDate, _ := utils.ParseDateString(req.TransferDate)
entityTransfer := &entity.StockTransfer{
FromWarehouseId: uint64(req.SourceWarehouseID),
ToWarehouseId: uint64(req.DestinationWarehouseID),
Reason: req.TransferReason,
TransferDate: transferDate,
MovementNumber: movementNumber,
CreatedBy: uint64(actorID),
}
expensePayloads := make([]TransferExpenseReceivingPayload, 0)
var detailMap map[uint64]*entity.StockTransferDetail
var createdTransfer *entity.StockTransfer
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
stockTransferRepoTX := s.StockTransferRepo.WithTx(tx)
stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx)
stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx)
stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx)
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
stocklogsRepoTx := s.StockLogsRepository.WithTx(tx)
if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil {
return err
}
details := make([]*entity.StockTransferDetail, 0, len(req.Products))
detailMap := make(map[uint64]*entity.StockTransferDetail)
for _, product := range req.Products {
sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
}
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context()
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID))
if err != nil {
return err
}
var pfkID *uint
if projectFlockKandangID > 0 {
pfkID = &projectFlockKandangID
}
destPW = &entity.ProductWarehouse{
ProductId: uint(product.ProductID),
WarehouseId: uint(req.DestinationWarehouseID),
Quantity: 0,
ProjectFlockKandangId: pfkID,
}
if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil {
s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
}
}
detail := &entity.StockTransferDetail{
StockTransferId: entityTransfer.Id,
ProductId: uint64(product.ProductID),
SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(),
UsageQty: 0,
PendingQty: 0,
DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(),
TotalQty: 0,
TotalUsed: 0,
}
details = append(details, detail)
detailMap[uint64(product.ProductID)] = detail
}
if err := stockTransferDetailRepoTX.CreateMany(c.Context(), details, nil); err != nil {
movementResult, err := s.createTransferMovement(c.Context(), tx, &SystemTransferRequest{
TransferReason: req.TransferReason,
TransferDate: transferDate,
SourceWarehouseID: uint(req.SourceWarehouseID),
DestinationWarehouseID: uint(req.DestinationWarehouseID),
Products: products,
ActorID: actorID,
})
if err != nil {
return err
}
detailMap = movementResult.DetailByPID
createdTransfer = movementResult.Transfer
var deliveries []*entity.StockTransferDelivery
for _, delivery := range req.Deliveries {
@@ -389,7 +300,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil
}()
deliveries = append(deliveries, &entity.StockTransferDelivery{
StockTransferId: entityTransfer.Id,
StockTransferId: createdTransfer.Id,
SupplierId: supplierId,
VehiclePlate: delivery.VehiclePlate,
DriverName: delivery.DriverName,
@@ -402,7 +313,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
var deliveryItems []*entity.StockTransferDeliveryItem
for i, delivery := range deliveries {
item := req.Deliveries[i]
for _, prod := range item.Products {
@@ -422,14 +332,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
if s.DocumentSvc != nil && len(files) > 0 {
for deliveryIdx, delivery := range deliveries {
reqDelivery := req.Deliveries[deliveryIdx]
if reqDelivery.DocumentIndex < 0 {
continue
}
if reqDelivery.DocumentIndex >= len(files) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("DocumentIndex %d untuk delivery %d melebihi jumlah file yang diupload (%d)",
@@ -437,14 +344,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
file := files[reqDelivery.DocumentIndex]
documentFiles := []commonSvc.DocumentFile{
{
File: file,
Type: string(utils.DocumentTypeTransfer),
Index: &reqDelivery.DocumentIndex,
},
}
documentFiles := []commonSvc.DocumentFile{{
File: file,
Type: string(utils.DocumentTypeTransfer),
Index: &reqDelivery.DocumentIndex,
}}
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeTransfer),
DocumentableID: delivery.Id,
@@ -459,160 +363,31 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
}
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
flagGroupByProduct := make(map[uint]string, len(req.Products))
for _, product := range req.Products {
detail := detailMap[uint64(product.ProductID)]
if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid")
for _, delivery := range req.Deliveries {
if delivery.SupplierID == 0 {
continue
}
flagGroupCode, ok := flagGroupByProduct[uint(product.ProductID)]
if !ok {
flagGroupCode, err = s.resolveTransferFlagGroup(c.Context(), tx, uint(product.ProductID))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err))
}
flagGroupByProduct[uint(product.ProductID)] = flagGroupCode
}
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"usage_qty": product.ProductQty,
"pending_qty": 0,
"total_qty": product.ProductQty,
}).Error; err != nil {
s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
asOf := transferDate
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err))
}
type usageSnapshot struct {
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
}
var usage usageSnapshot
if err := tx.WithContext(c.Context()).
Table("stock_transfer_details").
Select("usage_qty, pending_qty").
Where("id = ?", detail.Id).
Take(&usage).Error; err != nil {
s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking")
}
outUsageQty := usage.UsageQty
outPendingQty := usage.PendingQty
if outPendingQty > 1e-6 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID))
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: uint(actorID),
Increase: 0,
Decrease: outUsageQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
}
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
inAddedQty := outUsageQty
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.DestProductWarehouseID),
CreatedBy: uint(actorID),
Increase: inAddedQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
}
stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
}
if len(req.Deliveries) > 0 {
for _, delivery := range req.Deliveries {
// Skip adding to expensePayloads if SupplierID is 0 (optional)
if delivery.SupplierID == 0 {
for _, prod := range delivery.Products {
detail := detailMap[uint64(prod.ProductID)]
if detail == nil {
continue
}
for _, prod := range delivery.Products {
detail := detailMap[uint64(prod.ProductID)]
if detail == nil {
continue
}
warehouseID := uint(req.DestinationWarehouseID)
supplierID := uint(delivery.SupplierID)
deliveredDate := transferDate
deliveredQty := prod.ProductQty
payload := TransferExpenseReceivingPayload{
TransferDetailID: detail.Id,
ProductID: uint64(prod.ProductID),
WarehouseID: uint64(warehouseID),
SupplierID: uint64(supplierID),
DeliveredQty: deliveredQty,
DeliveredDate: &deliveredDate,
}
expensePayloads = append(expensePayloads, payload)
}
warehouseID := uint(req.DestinationWarehouseID)
supplierID := uint(delivery.SupplierID)
deliveredDate := transferDate
expensePayloads = append(expensePayloads, TransferExpenseReceivingPayload{
TransferDetailID: detail.Id,
ProductID: uint64(prod.ProductID),
WarehouseID: uint64(warehouseID),
SupplierID: uint64(supplierID),
DeliveredQty: prod.ProductQty,
DeliveredDate: &deliveredDate,
})
}
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
@@ -620,14 +395,13 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
}
result, err := s.GetOne(c, uint(entityTransfer.Id))
result, err := s.GetOne(c, uint(createdTransfer.Id))
if err != nil {
return nil, err
}
if len(expensePayloads) > 0 {
if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil {
s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", entityTransfer.Id, entityTransfer.MovementNumber, err)
if err := s.notifyExpenseItemsDelivered(c, createdTransfer.Id, expensePayloads); err != nil {
s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", createdTransfer.Id, createdTransfer.MovementNumber, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense")
}
}
@@ -650,177 +424,9 @@ func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error {
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
var err error
deletedDetails, err = s.deleteTransferCore(c.Context(), tx, uint64(id), actorID)
return err
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
@@ -863,13 +469,31 @@ func (s *transferService) resolveTransferFlagGroup(
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 = ?
AND fm.flag_group_code = rr.flag_group_code
FROM products p
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE p.id = ?
AND (
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 = p.id
AND fm.flag_group_code = rr.flag_group_code
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = ?
AND f_any.flagable_id = p.id
)
AND rr.flag_group_code = ?
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
)
`, entity.FlagableTypeProduct, productID).
`, productID, entity.FlagableTypeProduct, entity.FlagableTypeProduct, utils.LegacyFlagGroupCodeByProductCategoryCode("EGG")).
Order("rr.id ASC").
Limit(1).
Take(&selected).Error
@@ -2,9 +2,10 @@ package repository
import (
"context"
"sort"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -12,7 +13,7 @@ import (
)
type MarketingDeliveryProductRepository interface {
repository.BaseRepository[entity.MarketingDeliveryProduct]
commonRepo.BaseRepository[entity.MarketingDeliveryProduct]
GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error)
GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error)
@@ -23,26 +24,27 @@ type MarketingDeliveryProductRepository interface {
GetUsageQty(ctx context.Context, id uint) (float64, error)
ResetFifoFields(ctx context.Context, id uint) error
GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error)
}
type MarketingDeliveryProductRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct]
*commonRepo.BaseRepositoryImpl[entity.MarketingDeliveryProduct]
}
func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository {
return &MarketingDeliveryProductRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db),
BaseRepositoryImpl: commonRepo.NewBaseRepository[entity.MarketingDeliveryProduct](db),
}
}
func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Joins("JOIN (?) AS mda ON mda.marketing_delivery_product_id = marketing_delivery_products.id", attributionQuery).
Where("mda.project_flock_id = ?", projectFlockID).
Distinct("marketing_delivery_products.*")
if callback != nil {
@@ -57,139 +59,50 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo
}
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("marketing_delivery_products.delivery_date IS NOT NULL").
Distinct("marketing_delivery_products.*")
if projectFlockKandangID != nil {
db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID)
}
db = db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Uom").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.Warehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Preload("MarketingProduct.Marketing").
Preload("MarketingProduct.Marketing.Customer").
Order("marketing_delivery_products.delivery_date DESC")
if err := db.Find(&deliveryProducts).Error; err != nil {
attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, nil)
if err != nil {
return nil, err
}
return deliveryProducts, nil
return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID)
}
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("flags.name IN (?)", []string{
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagPullet),
string(utils.FlagLayer),
}).
Where("marketing_delivery_products.delivery_date IS NOT NULL").
Distinct("marketing_delivery_products.*")
if projectFlockKandangID != nil {
db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID)
}
db = db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Order("marketing_delivery_products.delivery_date DESC")
if err := db.Find(&deliveryProducts).Error; err != nil {
attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, []string{
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagPullet),
string(utils.FlagLayer),
})
if err != nil {
return nil, err
}
return deliveryProducts, nil
return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID)
}
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("marketing_delivery_products.delivery_date IS NOT NULL").
Distinct("marketing_delivery_products.*")
if projectFlockKandangID != nil {
db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID)
flagNames := []string{
string(utils.FlagDOC),
string(utils.FlagPullet),
string(utils.FlagLayer),
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagAyamMati),
}
if category == string(utils.ProjectFlockCategoryLaying) {
db = db.Where("flags.name IN (?)", []string{
flagNames = []string{
string(utils.FlagTelur),
string(utils.FlagTelurUtuh),
string(utils.FlagTelurPecah),
string(utils.FlagTelurPutih),
string(utils.FlagTelurRetak),
})
} else {
db = db.Where("flags.name IN (?)", []string{
string(utils.FlagDOC),
string(utils.FlagPullet),
string(utils.FlagLayer),
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagAyamMati),
})
}
}
db = db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Uom").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.Warehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Preload("MarketingProduct.Marketing").
Preload("MarketingProduct.Marketing.Customer").
Order("marketing_delivery_products.delivery_date DESC")
if err := db.Find(&deliveryProducts).Error; err != nil {
attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, flagNames)
if err != nil {
return nil, err
}
return deliveryProducts, nil
return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID)
}
func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) {
@@ -219,12 +132,199 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con
return &deliveryProduct, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error) {
if len(deliveryProductIDs) == 0 {
return []commonRepo.MarketingDeliveryAttributionRow{}, nil
}
var rows []commonRepo.MarketingDeliveryAttributionRow
query := r.DB().WithContext(ctx).
Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))).
Where("mda.marketing_delivery_product_id IN ?", deliveryProductIDs).
Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC")
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) getClosingAttributionRows(
ctx context.Context,
projectFlockID uint,
projectFlockKandangID *uint,
flagNames []string,
) ([]commonRepo.MarketingDeliveryAttributionRow, error) {
var rows []commonRepo.MarketingDeliveryAttributionRow
attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))
query := r.DB().WithContext(ctx).
Table("(?) AS mda", attributionQuery).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = mda.marketing_delivery_product_id").
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id").
Where("mda.project_flock_id = ?", projectFlockID).
Where("mdp.delivery_date IS NOT NULL")
if projectFlockKandangID != nil {
query = query.Where("mda.project_flock_kandang_id = ?", *projectFlockKandangID)
}
if len(flagNames) > 0 {
query = query.
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.name IN ?", flagNames)
}
query = query.
Select(`
mda.marketing_delivery_product_id,
mda.project_flock_kandang_id,
mda.project_flock_id,
mda.project_flock_category,
SUM(mda.allocated_qty) AS allocated_qty
`).
Group(`
mda.marketing_delivery_product_id,
mda.project_flock_kandang_id,
mda.project_flock_id,
mda.project_flock_category
`).
Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC")
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) fetchClosingDeliveryProducts(
ctx context.Context,
attributionRows []commonRepo.MarketingDeliveryAttributionRow,
projectFlockKandangID *uint,
) ([]entity.MarketingDeliveryProduct, error) {
deliveryIDs := orderedDeliveryProductIDs(attributionRows)
if len(deliveryIDs) == 0 {
return []entity.MarketingDeliveryProduct{}, nil
}
query := r.closingDeliveryProductsQuery(ctx).
Where("marketing_delivery_products.id IN ?", deliveryIDs).
Order("marketing_delivery_products.delivery_date DESC")
if projectFlockKandangID == nil {
query = query.Joins(
"LEFT JOIN (?) AS mda_single ON mda_single.marketing_delivery_product_id = marketing_delivery_products.id",
commonRepo.MarketingDeliverySingleAttributionQuery(r.DB().WithContext(ctx)),
).Select("marketing_delivery_products.*, mda_single.attributed_project_flock_kandang_id")
}
var deliveryProducts []entity.MarketingDeliveryProduct
if err := query.Find(&deliveryProducts).Error; err != nil {
return nil, err
}
if projectFlockKandangID == nil {
return deliveryProducts, nil
}
return scaleDeliveryProductsByAttribution(deliveryProducts, attributionRows, *projectFlockKandangID), nil
}
func (r *MarketingDeliveryProductRepositoryImpl) closingDeliveryProductsQuery(ctx context.Context) *gorm.DB {
return r.DB().WithContext(ctx).
Model(&entity.MarketingDeliveryProduct{}).
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Uom").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.Warehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Preload("MarketingProduct.Marketing").
Preload("MarketingProduct.Marketing.Customer").
Preload("AttributedProjectFlockKandang").
Preload("AttributedProjectFlockKandang.ProjectFlock").
Preload("AttributedProjectFlockKandang.Kandang").
Preload("AttributedProjectFlockKandang.Chickins")
}
func orderedDeliveryProductIDs(rows []commonRepo.MarketingDeliveryAttributionRow) []uint {
seen := make(map[uint]struct{}, len(rows))
ids := make([]uint, 0, len(rows))
for _, row := range rows {
if row.MarketingDeliveryProductID == 0 {
continue
}
if _, ok := seen[row.MarketingDeliveryProductID]; ok {
continue
}
seen[row.MarketingDeliveryProductID] = struct{}{}
ids = append(ids, row.MarketingDeliveryProductID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func scaleDeliveryProductsByAttribution(
deliveryProducts []entity.MarketingDeliveryProduct,
rows []commonRepo.MarketingDeliveryAttributionRow,
projectFlockKandangID uint,
) []entity.MarketingDeliveryProduct {
if len(deliveryProducts) == 0 || projectFlockKandangID == 0 {
return deliveryProducts
}
totalByDelivery := make(map[uint]float64, len(rows))
selectedByDelivery := make(map[uint]float64, len(rows))
for _, row := range rows {
totalByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty
if row.ProjectFlockKandangID == projectFlockKandangID {
selectedByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty
}
}
filtered := make([]entity.MarketingDeliveryProduct, 0, len(deliveryProducts))
for _, delivery := range deliveryProducts {
selectedQty := selectedByDelivery[delivery.Id]
totalQty := totalByDelivery[delivery.Id]
if selectedQty <= 0 {
continue
}
share := 1.0
if totalQty > 0 {
share = selectedQty / totalQty
}
cloned := delivery
cloned.AttributedProjectFlockKandangId = &projectFlockKandangID
cloned.UsageQty = selectedQty
cloned.PendingQty = 0
cloned.TotalWeight = delivery.TotalWeight * share
cloned.TotalPrice = delivery.TotalPrice * share
filtered = append(filtered, cloned)
}
return filtered
}
func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
var total int64
baseDB := r.DB().WithContext(ctx)
singleAttributionQuery := commonRepo.MarketingDeliverySingleAttributionQuery(baseDB)
db := r.DB().WithContext(ctx).
Model(&entity.MarketingDeliveryProduct{}).
Select("marketing_delivery_products.*, mda_single.attributed_project_flock_kandang_id").
Joins("LEFT JOIN (?) AS mda_single ON mda_single.marketing_delivery_product_id = marketing_delivery_products.id", singleAttributionQuery).
Preload("MarketingProduct", func(db *gorm.DB) *gorm.DB {
return db.
Preload("Marketing").
@@ -237,6 +337,9 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock")
}).
Preload("AttributedProjectFlockKandang").
Preload("AttributedProjectFlockKandang.ProjectFlock").
Preload("AttributedProjectFlockKandang.Kandang").
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id").
Where("marketing_delivery_products.delivery_date IS NOT NULL")
@@ -292,22 +395,27 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
}
if filters.AreaId > 0 || filters.LocationId > 0 || filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil {
db = db.Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id")
buildAttrFilter := func() *gorm.DB {
return r.DB().WithContext(ctx).
Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))).
Select("1").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = mda.project_flock_kandang_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("mda.marketing_delivery_product_id = marketing_delivery_products.id")
}
if filters.AreaId > 0 {
db = db.Where("project_flocks.area_id = ?", filters.AreaId)
db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.area_id = ?", filters.AreaId))
}
if filters.LocationId > 0 {
db = db.Where("project_flocks.location_id = ?", filters.LocationId)
db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.location_id = ?", filters.LocationId))
}
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("project_flocks.area_id IN ?", filters.AllowedAreaIDs)
db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.area_id IN ?", filters.AllowedAreaIDs))
}
}
@@ -315,7 +423,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("project_flocks.location_id IN ?", filters.AllowedLocationIDs)
db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.location_id IN ?", filters.AllowedLocationIDs))
}
}
}
@@ -0,0 +1,42 @@
package repository
import (
"testing"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func TestScaleDeliveryProductsByAttribution(t *testing.T) {
projectFlockKandangID := uint(101)
deliveryProducts := []entity.MarketingDeliveryProduct{
{
Id: 55,
UsageQty: 100,
TotalWeight: 180,
TotalPrice: 3600,
},
}
attributionRows := []commonRepo.MarketingDeliveryAttributionRow{
{MarketingDeliveryProductID: 55, ProjectFlockKandangID: 101, AllocatedQty: 60},
{MarketingDeliveryProductID: 55, ProjectFlockKandangID: 102, AllocatedQty: 40},
}
got := scaleDeliveryProductsByAttribution(deliveryProducts, attributionRows, projectFlockKandangID)
if len(got) != 1 {
t.Fatalf("expected 1 scaled delivery, got %d", len(got))
}
if got[0].UsageQty != 60 {
t.Fatalf("expected usage qty 60, got %.2f", got[0].UsageQty)
}
if got[0].TotalWeight != 108 {
t.Fatalf("expected total weight 108, got %.2f", got[0].TotalWeight)
}
if got[0].TotalPrice != 2160 {
t.Fatalf("expected total price 2160, got %.2f", got[0].TotalPrice)
}
if got[0].AttributedProjectFlockKandangId == nil || *got[0].AttributedProjectFlockKandangId != projectFlockKandangID {
t.Fatalf("expected attributed kandang id %d, got %+v", projectFlockKandangID, got[0].AttributedProjectFlockKandangId)
}
}
@@ -127,18 +127,30 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
Preload("Products.DeliveryProduct")
if params.Status != "" {
status := strings.TrimSpace(params.Status)
latestApprovalSubQuery := s.MarketingRepo.DB().
WithContext(c.Context()).
Table("approvals").
Select("DISTINCT ON (approvable_id) approvable_id, step_name").
Select("DISTINCT ON (approvable_id) approvable_id, step_name, action").
Where("approvable_type = ?", utils.ApprovalWorkflowMarketing.String()).
Order("approvable_id, id DESC")
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = marketings.id
AND LOWER(latest_approval.step_name) = LOWER(?)
)`, latestApprovalSubQuery, params.Status)
if strings.EqualFold(status, "DITOLAK") {
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = marketings.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
} else {
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = marketings.id
AND LOWER(latest_approval.step_name) = LOWER(?)
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
)`, latestApprovalSubQuery, status, string(entity.ApprovalActionRejected))
}
}
if params.Search != "" {
@@ -213,7 +225,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
if err != nil {
return nil, 0, err
}
@@ -258,7 +269,6 @@ func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDeta
return db.Preload("ActionUser")
})
if err != nil {
} else if len(approvals) > 0 {
if marketing.LatestApproval == nil {
latest := approvals[len(approvals)-1]
@@ -310,7 +320,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
}
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
@@ -377,7 +386,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber
if requestedProduct.Qty > 0 {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil {
return err
}
@@ -404,7 +412,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
@@ -436,7 +443,6 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
}
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction)
@@ -525,7 +531,6 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
@@ -558,6 +563,12 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
if deliveryProduct == nil || deliveryProduct.Id == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found")
}
if deliveryProduct.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Delivery product warehouse not found")
}
if deliveryProduct.ProductWarehouseId != marketingProduct.ProductWarehouseId {
return fiber.NewError(fiber.StatusBadRequest, "Delivery product warehouse mismatch with marketing product")
}
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
previousUsage := deliveryProduct.UsageQty
@@ -595,12 +606,12 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
Decrease: allocatedDelta,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
ProductWarehouseId: deliveryProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: fmt.Sprintf("FIFO v2 reflow consume (%.2f)", allocatedDelta),
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, deliveryProduct.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
@@ -632,6 +643,11 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
return nil
}
affectedKandangIDs, err := s.marketingPopulationKandangIDsFromActiveAllocations(ctx, tx, deliveryProduct.Id)
if err != nil {
return err
}
deliveryProduct.UsageQty = 0
deliveryProduct.PendingQty = 0
if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
@@ -659,6 +675,9 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err
}
if err := s.resyncPopulationUsageByKandangIDs(ctx, tx, affectedKandangIDs); err != nil {
return err
}
releasedUsage := currentUsage - deliveryProduct.UsageQty
if actorID > 0 && releasedUsage > 0 {
@@ -714,29 +733,378 @@ func (s deliveryOrdersService) allocatePopulationForMarketingDelivery(
return nil
}
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil)
exactAllocations, err := s.findDirectPopulationAllocationsForMarketing(ctx, tx, deliveryProduct.Id)
if err != nil {
return err
}
if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 {
if len(exactAllocations) > 0 {
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err
}
if err := s.applyDirectPopulationAllocationsForMarketing(ctx, tx, productWarehouseID, deliveryProduct.Id, exactAllocations); err != nil {
return err
}
return s.resyncPopulationUsageByKandangIDs(ctx, tx, marketingAllocationKandangIDs(exactAllocations))
}
sourceGroups, err := s.findPopulationSourceGroupsForMarketing(ctx, tx, deliveryProduct.Id, productWarehouseID)
if err != nil {
return err
}
if len(sourceGroups) == 0 {
return nil
}
populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(ctx, *pw.ProjectFlockKandangId, productWarehouseID)
if err != nil {
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery")
for _, group := range sourceGroups {
populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(
ctx,
group.ProjectFlockKandangID,
group.ProductWarehouseID,
)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery")
}
if err := s.allocatePopulationConsumptionWithoutRelease(
ctx,
tx,
populations,
productWarehouseID,
deliveryProduct.Id,
group.Qty,
); err != nil {
return err
}
}
return s.resyncPopulationUsageByKandangIDs(ctx, tx, marketingSourceGroupKandangIDs(sourceGroups))
}
type marketingPopulationAllocation struct {
ProjectFlockPopulationID uint `gorm:"column:project_flock_population_id"`
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
Qty float64 `gorm:"column:qty"`
}
type marketingPopulationSourceGroup struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
Qty float64 `gorm:"column:qty"`
}
func (s deliveryOrdersService) findDirectPopulationAllocationsForMarketing(
ctx context.Context,
tx *gorm.DB,
deliveryProductID uint,
) ([]marketingPopulationAllocation, error) {
var rows []marketingPopulationAllocation
err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
pfp.id AS project_flock_population_id,
pc.project_flock_kandang_id AS project_flock_kandang_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Group("pfp.id, pc.project_flock_kandang_id").
Order("pfp.id ASC").
Scan(&rows).Error
return rows, err
}
func (s deliveryOrdersService) findPopulationSourceGroupsForMarketing(
ctx context.Context,
tx *gorm.DB,
deliveryProductID uint,
productWarehouseID uint,
) ([]marketingPopulationSourceGroup, error) {
groups := make(map[string]marketingPopulationSourceGroup)
appendGroup := func(projectFlockKandangID uint, sourceProductWarehouseID uint, qty float64) {
if projectFlockKandangID == 0 || sourceProductWarehouseID == 0 || qty <= 0 {
return
}
key := fmt.Sprintf("%d:%d", projectFlockKandangID, sourceProductWarehouseID)
current := groups[key]
current.ProjectFlockKandangID = projectFlockKandangID
current.ProductWarehouseID = sourceProductWarehouseID
current.Qty += qty
groups[key] = current
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
productWarehouseID,
fifo.UsableKeyMarketingDelivery.String(),
deliveryProduct.Id,
deliveryProduct.UsageQty,
)
var transferRows []marketingPopulationSourceGroup
if err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
source_pw.project_flock_kandang_id AS project_flock_kandang_id,
std.source_product_warehouse_id AS product_warehouse_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id").
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("source_pw.project_flock_kandang_id IS NOT NULL").
Group("source_pw.project_flock_kandang_id, std.source_product_warehouse_id").
Scan(&transferRows).Error; err != nil {
return nil, err
}
for _, row := range transferRows {
appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty)
}
var purchaseRows []marketingPopulationSourceGroup
if err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
pi.project_flock_kandang_id AS project_flock_kandang_id,
pi.product_warehouse_id AS product_warehouse_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("pi.project_flock_kandang_id IS NOT NULL").
Where("pi.product_warehouse_id IS NOT NULL").
Group("pi.project_flock_kandang_id, pi.product_warehouse_id").
Scan(&purchaseRows).Error; err != nil {
return nil, err
}
for _, row := range purchaseRows {
appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty)
}
var layingRows []marketingPopulationSourceGroup
if err := tx.WithContext(ctx).
Table("stock_allocations sa").
Select(`
ltt.target_project_flock_kandang_id AS project_flock_kandang_id,
ltt.product_warehouse_id AS product_warehouse_id,
SUM(sa.qty) AS qty
`).
Joins("JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("ltt.product_warehouse_id IS NOT NULL").
Group("ltt.target_project_flock_kandang_id, ltt.product_warehouse_id").
Scan(&layingRows).Error; err != nil {
return nil, err
}
for _, row := range layingRows {
appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty)
}
if len(groups) == 0 {
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil)
if err != nil {
return nil, err
}
if pw.ProjectFlockKandangId != nil && *pw.ProjectFlockKandangId != 0 {
appendGroup(*pw.ProjectFlockKandangId, productWarehouseID, 0)
}
}
result := make([]marketingPopulationSourceGroup, 0, len(groups))
for _, group := range groups {
if group.Qty == 0 {
group.Qty = s.resolveMarketingRequestedUsageQty(ctx, tx, deliveryProductID)
}
if group.Qty > 0 {
result = append(result, group)
}
}
return result, nil
}
func (s deliveryOrdersService) applyDirectPopulationAllocationsForMarketing(
ctx context.Context,
tx *gorm.DB,
productWarehouseID uint,
deliveryProductID uint,
allocations []marketingPopulationAllocation,
) error {
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
for _, allocation := range allocations {
if allocation.ProjectFlockPopulationID == 0 || allocation.Qty <= 0 {
continue
}
record := &entity.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
StockableId: allocation.ProjectFlockPopulationID,
UsableType: fifo.UsableKeyMarketingDelivery.String(),
UsableId: deliveryProductID,
Qty: allocation.Qty,
Status: entity.StockAllocationStatusActive,
AllocationPurpose: entity.StockAllocationPurposeConsume,
}
if err := stockAllocationRepo.CreateOne(ctx, record, nil); err != nil {
return err
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", allocation.ProjectFlockPopulationID).
Update("total_used_qty", gorm.Expr("total_used_qty + ?", allocation.Qty)).Error; err != nil {
return err
}
}
return nil
}
func (s deliveryOrdersService) allocatePopulationConsumptionWithoutRelease(
ctx context.Context,
tx *gorm.DB,
populations []entity.ProjectFlockPopulation,
productWarehouseID uint,
deliveryProductID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
remaining := consumeQty
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
for _, population := range populations {
available := population.TotalQty - population.TotalUsedQty
if available <= 0 {
continue
}
portion := available
if remaining < portion {
portion = remaining
}
if portion <= 0 {
continue
}
record := &entity.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
StockableId: population.Id,
UsableType: fifo.UsableKeyMarketingDelivery.String(),
UsableId: deliveryProductID,
Qty: portion,
Status: entity.StockAllocationStatusActive,
AllocationPurpose: entity.StockAllocationPurposeConsume,
}
if err := stockAllocationRepo.CreateOne(ctx, record, nil); err != nil {
return err
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", population.Id).
Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil {
return err
}
remaining -= portion
if remaining <= 0.000001 {
break
}
}
if remaining > 0.000001 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi")
}
return nil
}
func (s deliveryOrdersService) marketingPopulationKandangIDsFromActiveAllocations(
ctx context.Context,
tx *gorm.DB,
deliveryProductID uint,
) ([]uint, error) {
var ids []uint
err := tx.WithContext(ctx).
Table("stock_allocations sa").
Distinct("pc.project_flock_kandang_id").
Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
deliveryProductID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Pluck("pc.project_flock_kandang_id", &ids).Error
return ids, err
}
func (s deliveryOrdersService) resyncPopulationUsageByKandangIDs(ctx context.Context, tx *gorm.DB, kandangIDs []uint) error {
for _, kandangID := range uniqueUintIDs(kandangIDs) {
if kandangID == 0 {
continue
}
if err := s.ProjectFlockPopulationRepo.WithTx(tx).ResyncUsageByProjectFlockKandangID(ctx, tx, kandangID); err != nil {
return err
}
}
return nil
}
func (s deliveryOrdersService) resolveMarketingRequestedUsageQty(ctx context.Context, tx *gorm.DB, deliveryProductID uint) float64 {
var usageQty float64
if err := tx.WithContext(ctx).
Table("marketing_delivery_products").
Select("usage_qty").
Where("id = ?", deliveryProductID).
Scan(&usageQty).Error; err != nil {
return 0
}
return usageQty
}
func marketingAllocationKandangIDs(rows []marketingPopulationAllocation) []uint {
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ProjectFlockKandangID)
}
return ids
}
func marketingSourceGroupKandangIDs(rows []marketingPopulationSourceGroup) []uint {
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ProjectFlockKandangID)
}
return ids
}
func uniqueUintIDs(ids []uint) []uint {
seen := make(map[uint]struct{}, len(ids))
result := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
@@ -141,6 +141,12 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Unit konversi tidak valid")
}
if item.MarketingType == string(utils.MarketingTypeTelur) &&
item.ConvertionUnit != nil &&
*item.ConvertionUnit == string(utils.ConvertionUnitPeti) &&
(item.WeightPerConvertion == nil || *item.WeightPerConvertion <= 0) {
return nil, fiber.NewError(fiber.StatusBadRequest, "weight_per_convertion wajib diisi dan > 0 untuk TELUR dengan convertion_unit PETI")
}
if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil {
return nil, err
}
@@ -151,6 +157,31 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
}
}
requestedByWarehouse := make(map[uint]float64)
for _, item := range req.MarketingProducts {
if item.ProductWarehouseId == 0 {
continue
}
requestedByWarehouse[item.ProductWarehouseId] += item.Qty
}
for pwID, requestedQty := range requestedByWarehouse {
productWarehouse, err := s.ProductWarehouseRepo.GetDetailByID(c.Context(), pwID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", pwID))
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock availability")
}
availableQty := productWarehouse.Quantity
if availableQty+1e-6 < requestedQty {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Stok tidak mencukupi untuk gudang %d: diminta %.3f, tersedia %.3f", pwID, requestedQty, availableQty),
)
}
}
soDate, err := utils.ParseDateString(req.Date)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
@@ -283,6 +314,12 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Unit konversi tidak valid")
}
if item.MarketingType == string(utils.MarketingTypeTelur) &&
item.ConvertionUnit != nil &&
*item.ConvertionUnit == string(utils.ConvertionUnitPeti) &&
(item.WeightPerConvertion == nil || *item.WeightPerConvertion <= 0) {
return nil, fiber.NewError(fiber.StatusBadRequest, "weight_per_convertion wajib diisi dan > 0 untuk TELUR dengan convertion_unit PETI")
}
if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil {
return nil, err
}
@@ -361,7 +398,15 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
for _, rp := range req.MarketingProducts {
if old, ok := oldByPW[rp.ProductWarehouseId]; ok {
totalWeight, totalPrice := s.calculatePriceByMarketingType(rp.MarketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week)
totalWeight, totalPrice := s.calculatePriceByMarketingType(
rp.MarketingType,
rp.Qty,
rp.AvgWeight,
rp.UnitPrice,
rp.Week,
rp.ConvertionUnit,
rp.WeightPerConvertion,
)
deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -725,7 +770,15 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
totalWeight, totalPrice := s.calculatePriceByMarketingType(marketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week)
totalWeight, totalPrice := s.calculatePriceByMarketingType(
marketingType,
rp.Qty,
rp.AvgWeight,
rp.UnitPrice,
rp.Week,
rp.ConvertionUnit,
rp.WeightPerConvertion,
)
marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId,
@@ -762,7 +815,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
return nil
}
func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) {
func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, weightPerConvertion *float64) (totalWeight, totalPrice float64) {
if marketingType == string(utils.MarketingTypeTrading) {
totalWeight = 0
totalPrice = math.Round(qty*unitPrice*100) / 100
@@ -771,6 +824,21 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string,
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100
} else {
totalWeight = math.Round(qty*avgWeight*100) / 100
if marketingType == string(utils.MarketingTypeTelur) && convertionUnit != nil {
switch *convertionUnit {
case string(utils.ConvertionUnitQty):
totalPrice = math.Round(qty*unitPrice*100) / 100
return totalWeight, totalPrice
case string(utils.ConvertionUnitPeti):
if weightPerConvertion != nil && *weightPerConvertion > 0 {
totalPeti := totalWeight / *weightPerConvertion
totalPrice = math.Round(totalPeti*unitPrice*100) / 100
return totalWeight, totalPrice
}
}
}
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
}
return totalWeight, totalPrice
@@ -4,7 +4,7 @@ import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
kandangGroupDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups/dto"
)
// === DTO Structs ===
@@ -15,12 +15,12 @@ type EmployeesRelationDTO struct {
}
type EmployeesListDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
Kandangs []kandangDTO.KandangRelationDTO `json:"kandangs"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Id uint `json:"id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
Kandangs []kandangGroupDTO.KandangGroupRelationDTO `json:"kandangs"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type EmployeesDetailDTO struct {
@@ -37,12 +37,12 @@ func ToEmployeesRelationDTO(e entity.Employees) EmployeesRelationDTO {
}
func ToEmployeesListDTO(e entity.Employees) EmployeesListDTO {
kandangs := make([]kandangDTO.KandangRelationDTO, 0, len(e.EmployeeKandangs))
kandangs := make([]kandangGroupDTO.KandangGroupRelationDTO, 0, len(e.EmployeeKandangs))
for _, rel := range e.EmployeeKandangs {
if rel.Kandang.Id == 0 {
continue
}
kandangs = append(kandangs, kandangDTO.ToKandangRelationDTO(rel.Kandang))
kandangs = append(kandangs, kandangGroupDTO.ToKandangGroupRelationDTO(rel.Kandang))
}
return EmployeesListDTO{
@@ -52,7 +52,7 @@ func (s employeesService) ensureEmployeeAccess(c *fiber.Ctx, employeeID uint) er
db := s.Repository.DB().WithContext(c.Context()).
Table("employees e").
Joins("JOIN employee_kandangs ek ON ek.employee_id = e.id").
Joins("JOIN kandangs k ON k.id = ek.kandang_id").
Joins("JOIN kandang_groups k ON k.id = ek.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("e.id = ?", employeeID).
@@ -79,7 +79,7 @@ func (s employeesService) ensureKandangIDsAccess(c *fiber.Ctx, kandangIDs []uint
}
db := s.Repository.DB().WithContext(c.Context()).
Table("kandangs k").
Table("kandang_groups k").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("k.id IN ?", kandangIDs)
@@ -109,7 +109,7 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
employeess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db = db.Joins("JOIN employee_kandangs ek ON ek.employee_id = employees.id").
Joins("JOIN kandangs k ON k.id = ek.kandang_id").
Joins("JOIN kandang_groups k ON k.id = ek.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id")
var scopeErr error
@@ -0,0 +1,146 @@
package controller
import (
"math"
"strconv"
"github.com/gofiber/fiber/v2"
"gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
)
type KandangGroupController struct {
KandangGroupService service.KandangGroupService
}
func NewKandangGroupController(kandangGroupService service.KandangGroupService) *KandangGroupController {
return &KandangGroupController{
KandangGroupService: kandangGroupService,
}
}
func (u *KandangGroupController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
LocationId: c.QueryInt("location_id", 0),
PicId: c.QueryInt("pic_id", 0),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.KandangGroupService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.KandangGroupListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all kandang groups successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToKandangGroupListDTOs(result),
})
}
func (u *KandangGroupController) GetOne(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.KandangGroupService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get kandang group successfully",
Data: dto.ToKandangGroupListDTO(*result),
})
}
func (u *KandangGroupController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.KandangGroupService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create kandang group successfully",
Data: dto.ToKandangGroupListDTO(*result),
})
}
func (u *KandangGroupController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.KandangGroupService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update kandang group successfully",
Data: dto.ToKandangGroupListDTO(*result),
})
}
func (u *KandangGroupController) 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.KandangGroupService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete kandang group successfully",
})
}
@@ -0,0 +1,114 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
type KandangGroupRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Pic *userDTO.UserRelationDTO `json:"pic,omitempty"`
}
type RecordingKandangDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type KandangGroupListDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Location locationDTO.LocationRelationDTO `json:"location"`
Pic userDTO.UserRelationDTO `json:"pic"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
RecordingKandangs []RecordingKandangDTO `json:"recording_kandangs"`
}
type KandangGroupDetailDTO struct {
KandangGroupListDTO
}
func ToKandangGroupRelationDTO(e entity.KandangGroup) KandangGroupRelationDTO {
var location *locationDTO.LocationRelationDTO
if e.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(e.Location)
location = &mapped
}
var pic *userDTO.UserRelationDTO
if e.Pic.Id != 0 {
mapped := userDTO.ToUserRelationDTO(e.Pic)
pic = &mapped
}
return KandangGroupRelationDTO{
Id: e.Id,
Name: e.Name,
Status: e.Status,
Location: location,
Pic: pic,
}
}
func ToKandangGroupListDTO(e entity.KandangGroup) KandangGroupListDTO {
var location locationDTO.LocationRelationDTO
if e.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(e.Location)
location = mapped
}
var pic userDTO.UserRelationDTO
if e.Pic.Id != 0 {
mapped := userDTO.ToUserRelationDTO(e.Pic)
pic = mapped
}
var createdUser userDTO.UserRelationDTO
if e.CreatedUser.Id != 0 {
mapped := userDTO.ToUserRelationDTO(e.CreatedUser)
createdUser = mapped
}
recordingKandangs := make([]RecordingKandangDTO, 0, len(e.Kandangs))
for _, kandang := range e.Kandangs {
recordingKandangs = append(recordingKandangs, RecordingKandangDTO{
Id: kandang.Id,
Name: kandang.Name,
})
}
return KandangGroupListDTO{
Id: e.Id,
Name: e.Name,
Status: e.Status,
Location: location,
Pic: pic,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser,
RecordingKandangs: recordingKandangs,
}
}
func ToKandangGroupListDTOs(e []entity.KandangGroup) []KandangGroupListDTO {
result := make([]KandangGroupListDTO, len(e))
for i, r := range e {
result[i] = ToKandangGroupListDTO(r)
}
return result
}
func ToKandangGroupDetailDTO(e entity.KandangGroup) KandangGroupDetailDTO {
return KandangGroupDetailDTO{
KandangGroupListDTO: ToKandangGroupListDTO(e),
}
}
@@ -0,0 +1,25 @@
package kandanggroups
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
rKandangGroup "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups/repositories"
sKandangGroup "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type KandangGroupModule struct{}
func (KandangGroupModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
kandangGroupRepo := rKandangGroup.NewKandangGroupRepository(db)
userRepo := rUser.NewUserRepository(db)
kandangGroupService := sKandangGroup.NewKandangGroupService(kandangGroupRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
KandangGroupRoutes(router, userService, kandangGroupService)
}
@@ -0,0 +1,60 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
type KandangGroupRepository interface {
repository.BaseRepository[entity.KandangGroup]
LocationExists(ctx context.Context, locationId uint) (bool, error)
PicExists(ctx context.Context, picId uint) (bool, error)
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
HasDailyChecklistRelation(ctx context.Context, kandangGroupId uint) (bool, error)
}
type KandangGroupRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.KandangGroup]
db *gorm.DB
}
func NewKandangGroupRepository(db *gorm.DB) KandangGroupRepository {
return &KandangGroupRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.KandangGroup](db),
db: db,
}
}
func (r *KandangGroupRepositoryImpl) LocationExists(ctx context.Context, locationId uint) (bool, error) {
return repository.Exists[entity.Location](ctx, r.db, locationId)
}
func (r *KandangGroupRepositoryImpl) PicExists(ctx context.Context, picId uint) (bool, error) {
return repository.Exists[entity.User](ctx, r.db, picId)
}
func (r *KandangGroupRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) {
return repository.ExistsByName[entity.KandangGroup](ctx, r.db, name, excludeID)
}
func (r *KandangGroupRepositoryImpl) HasDailyChecklistRelation(ctx context.Context, kandangGroupId uint) (bool, error) {
var marker int
err := r.db.WithContext(ctx).
Model(&entity.DailyChecklist{}).
Select("1").
Where("kandang_id = ?", kandangGroupId).
Limit(1).
Take(&marker).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
@@ -0,0 +1,23 @@
package kandanggroups
import (
"github.com/gofiber/fiber/v2"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups/controllers"
kandanggroup "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
func KandangGroupRoutes(v1 fiber.Router, u user.UserService, s kandanggroup.KandangGroupService) {
ctrl := controller.NewKandangGroupController(s)
route := v1.Group("/kandang-groups")
route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_KandangGroups), ctrl.CreateOne)
route.Get("/:id", m.RequirePermissions(m.P_KandangGroups), ctrl.GetOne)
route.Patch("/:id", m.RequirePermissions(m.P_KandangGroups), ctrl.UpdateOne)
route.Delete("/:id", m.RequirePermissions(m.P_KandangGroups), ctrl.DeleteOne)
}
@@ -0,0 +1,252 @@
package service
import (
"errors"
"fmt"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
common "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
type KandangGroupService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.KandangGroup, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.KandangGroup, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.KandangGroup, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.KandangGroup, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
}
type kandangGroupService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.KandangGroupRepository
}
func NewKandangGroupService(repo repository.KandangGroupRepository, validate *validator.Validate) KandangGroupService {
return &kandangGroupService{
Log: utils.Log,
Validate: validate,
Repository: repo,
}
}
func (s kandangGroupService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("Location").
Preload("Pic").
Preload("Kandangs", func(tx *gorm.DB) *gorm.DB {
return tx.Select("id", "name", "kandang_group_id").Order("name ASC")
})
}
func (s kandangGroupService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.KandangGroup, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
var scopeErr error
offset := (params.Page - 1) * params.Limit
kandangGroups, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db, scopeErr = m.ApplyLocationScope(c, db, "kandang_groups.location_id")
if params.Search != "" {
db = db.Where("kandang_groups.name ILIKE ?", "%"+params.Search+"%")
}
if params.LocationId != 0 {
db = db.Where("kandang_groups.location_id = ?", params.LocationId)
}
if params.PicId != 0 {
db = db.Where("kandang_groups.pic_id = ?", params.PicId)
}
return db.Order("kandang_groups.created_at DESC").Order("kandang_groups.updated_at DESC")
})
if scopeErr != nil {
return nil, 0, scopeErr
}
if err != nil {
s.Log.Errorf("Failed to get kandang groups: %+v", err)
return nil, 0, err
}
return kandangGroups, total, nil
}
func (s kandangGroupService) GetOne(c *fiber.Ctx, id uint) (*entity.KandangGroup, error) {
var scopeErr error
kandangGroup, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db, scopeErr = m.ApplyLocationScope(c, db, "kandang_groups.location_id")
return db
})
if scopeErr != nil {
return nil, scopeErr
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Kandang group not found")
}
if err != nil {
s.Log.Errorf("Failed to get kandang group by id: %+v", err)
return nil, err
}
return kandangGroup, nil
}
func (s *kandangGroupService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.KandangGroup, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if err := m.EnsureLocationAccess(c, s.Repository.DB(), req.LocationId); err != nil {
return nil, err
}
if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil {
s.Log.Errorf("Failed to check kandang group name: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check kandang group name")
} else if exists {
return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang group with name %s already exists", req.Name))
}
if err := common.EnsureRelations(c.Context(),
common.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists},
common.RelationCheck{Name: "Pic", ID: &req.PicId, Exists: s.Repository.PicExists},
); err != nil {
return nil, err
}
status := strings.ToUpper(strings.TrimSpace(req.Status))
if status == "" {
status = string(utils.KandangStatusNonActive)
}
if !utils.IsValidKandangStatus(status) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang group status")
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
createBody := &entity.KandangGroup{
Name: req.Name,
Status: status,
LocationId: req.LocationId,
PicId: req.PicId,
CreatedBy: actorID,
}
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil {
s.Log.Errorf("Failed to create kandang group: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s kandangGroupService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.KandangGroup, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
existing, err := s.GetOne(c, id)
if err != nil {
return nil, err
}
if req.LocationId != nil {
if err := m.EnsureLocationAccess(c, s.Repository.DB(), *req.LocationId); err != nil {
return nil, err
}
}
updateBody := make(map[string]any)
if req.Name != nil {
if exists, err := s.Repository.NameExists(c.Context(), *req.Name, &id); err != nil {
s.Log.Errorf("Failed to check kandang group name: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check kandang group name")
} else if exists {
return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang group with name %s already exists", *req.Name))
}
updateBody["name"] = *req.Name
}
if err := common.EnsureRelations(c.Context(),
common.RelationCheck{Name: "Location", ID: req.LocationId, Exists: s.Repository.LocationExists},
common.RelationCheck{Name: "Pic", ID: req.PicId, Exists: s.Repository.PicExists},
); err != nil {
return nil, err
}
if req.LocationId != nil {
updateBody["location_id"] = *req.LocationId
}
if req.PicId != nil {
updateBody["pic_id"] = *req.PicId
}
if req.Status != nil {
status := strings.ToUpper(strings.TrimSpace(*req.Status))
if !utils.IsValidKandangStatus(status) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang group status")
}
updateBody["status"] = status
}
if len(updateBody) == 0 {
return existing, nil
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Kandang group not found")
}
s.Log.Errorf("Failed to update kandang group: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func (s kandangGroupService) DeleteOne(c *fiber.Ctx, id uint) error {
kandangGroup, err := s.GetOne(c, id)
if err != nil {
return err
}
hasDailyChecklistRelation, err := s.Repository.HasDailyChecklistRelation(c.Context(), id)
if err != nil {
s.Log.Errorf("Failed to check daily checklist relation for kandang group %d: %+v", id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check kandang group relation")
}
if hasDailyChecklistRelation {
return fiber.NewError(fiber.StatusConflict, "Kandang group tidak boleh dihapus karena masih memiliki relasi daily checklist")
}
if len(kandangGroup.Kandangs) > 0 {
return fiber.NewError(fiber.StatusConflict, "Kandang group tidak boleh dihapus karena masih memiliki relasi kandang")
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Kandang group not found")
}
s.Log.Errorf("Failed to delete kandang group: %+v", err)
return err
}
return nil
}
@@ -0,0 +1,23 @@
package validation
type Create struct {
Name string `json:"name" validate:"required_strict,min=3,max=50"`
Status string `json:"status,omitempty" validate:"omitempty,min=3,max=50"`
LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"`
PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"`
}
type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty,max=50"`
Status *string `json:"status,omitempty" validate:"omitempty,min=3,max=50"`
LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"`
PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"`
Search string `query:"search" validate:"omitempty,max=50"`
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
}
@@ -11,24 +11,32 @@ import (
// === DTO Structs ===
type KandangRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Capacity float64 `json:"capacity"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Pic *userDTO.UserRelationDTO `json:"pic,omitempty"`
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Capacity float64 `json:"capacity"`
KandangGroup *KandangGroupRelationDTO `json:"kandang_group,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Pic *userDTO.UserRelationDTO `json:"pic,omitempty"`
}
type KandangGroupRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
}
type KandangListDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Capacity float64 `json:"capacity"`
Location locationDTO.LocationRelationDTO `json:"location"`
Pic userDTO.UserRelationDTO `json:"pic"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Capacity float64 `json:"capacity"`
KandangGroup KandangGroupRelationDTO `json:"kandang_group"`
Location locationDTO.LocationRelationDTO `json:"location"`
Pic userDTO.UserRelationDTO `json:"pic"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type KandangDetailDTO struct {
@@ -38,6 +46,12 @@ type KandangDetailDTO struct {
// === Mapper Functions ===
func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO {
var kandangGroup *KandangGroupRelationDTO
if e.KandangGroup.Id != 0 {
mapped := ToKandangGroupRelationDTO(e.KandangGroup)
kandangGroup = &mapped
}
var location *locationDTO.LocationRelationDTO
if e.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(e.Location)
@@ -51,16 +65,31 @@ func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO {
}
return KandangRelationDTO{
Id: e.Id,
Name: e.Name,
Status: e.Status,
Capacity: e.Capacity,
Location: location,
Pic: pic,
Id: e.Id,
Name: e.Name,
Status: e.Status,
Capacity: e.Capacity,
KandangGroup: kandangGroup,
Location: location,
Pic: pic,
}
}
func ToKandangGroupRelationDTO(e entity.KandangGroup) KandangGroupRelationDTO {
return KandangGroupRelationDTO{
Id: e.Id,
Name: e.Name,
Status: e.Status,
}
}
func ToKandangListDTO(e entity.Kandang) KandangListDTO {
var kandangGroup KandangGroupRelationDTO
if e.KandangGroup.Id != 0 {
mapped := ToKandangGroupRelationDTO(e.KandangGroup)
kandangGroup = mapped
}
var location locationDTO.LocationRelationDTO
if e.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(e.Location)
@@ -80,15 +109,16 @@ func ToKandangListDTO(e entity.Kandang) KandangListDTO {
}
return KandangListDTO{
Id: e.Id,
Name: e.Name,
Status: e.Status,
Location: location,
Capacity: e.Capacity,
Pic: pic,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser,
Id: e.Id,
Name: e.Name,
Status: e.Status,
Location: location,
KandangGroup: kandangGroup,
Capacity: e.Capacity,
Pic: pic,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser,
}
}
@@ -4,10 +4,10 @@ import (
"context"
"errors"
"github.com/gofiber/fiber/v2"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
@@ -15,6 +15,7 @@ type KandangRepository interface {
repository.BaseRepository[entity.Kandang]
LocationExists(ctx context.Context, areaId uint) (bool, error)
PicExists(ctx context.Context, areaId uint) (bool, error)
KandangGroupExists(ctx context.Context, kandangGroupId uint) (bool, error)
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
ProjectFlockExists(ctx context.Context, projectFlockID uint) (bool, error)
GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error)
@@ -45,6 +46,10 @@ func (r *KandangRepositoryImpl) PicExists(ctx context.Context, picId uint) (bool
return repository.Exists[entity.User](ctx, r.db, picId)
}
func (r *KandangRepositoryImpl) KandangGroupExists(ctx context.Context, kandangGroupId uint) (bool, error) {
return repository.Exists[entity.KandangGroup](ctx, r.db, kandangGroupId)
}
func (r *KandangRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) {
return repository.ExistsByName[entity.Kandang](ctx, r.db, name, excludeID)
}
@@ -3,13 +3,14 @@ package service
import (
"errors"
"fmt"
"strings"
common "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -40,7 +41,7 @@ func NewKandangService(repo repository.KandangRepository, validate *validator.Va
}
func (s kandangService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser").Preload("Location").Preload("Pic").Preload("ProjectFlockKandangs.ProjectFlock")
return db.Preload("CreatedUser").Preload("Location").Preload("KandangGroup").Preload("Pic").Preload("ProjectFlockKandangs.ProjectFlock")
}
@@ -99,6 +100,28 @@ func (s kandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Kandang, error) {
return kandang, nil
}
func (s kandangService) ensureKandangGroupAccess(c *fiber.Ctx, groupID uint, expectedLocationID *uint) error {
var kandangGroup entity.KandangGroup
if err := s.Repository.DB().WithContext(c.Context()).
Select("id", "location_id").
First(&kandangGroup, groupID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Kandang group not found")
}
return err
}
if err := m.EnsureLocationAccess(c, s.Repository.DB(), kandangGroup.LocationId); err != nil {
return fiber.NewError(fiber.StatusNotFound, "Kandang group not found")
}
if expectedLocationID != nil && kandangGroup.LocationId != *expectedLocationID {
return fiber.NewError(fiber.StatusBadRequest, "Kandang group location must match kandang location")
}
return nil
}
func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Kandang, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
@@ -116,10 +139,14 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
if err := common.EnsureRelations(c.Context(),
common.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists},
common.RelationCheck{Name: "KandangGroup", ID: &req.GroupId, Exists: s.Repository.KandangGroupExists},
common.RelationCheck{Name: "Pic", ID: &req.PicId, Exists: s.Repository.PicExists},
); err != nil {
return nil, err
}
if err := s.ensureKandangGroupAccess(c, req.GroupId, &req.LocationId); err != nil {
return nil, err
}
status := strings.ToUpper(strings.TrimSpace(req.Status))
if status == "" {
@@ -154,12 +181,13 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
}
createBody := &entity.Kandang{
Name: req.Name,
LocationId: req.LocationId,
Capacity: req.Capacity,
Status: status,
PicId: req.PicId,
CreatedBy: actorID,
Name: req.Name,
LocationId: req.LocationId,
KandangGroupId: req.GroupId,
Capacity: req.Capacity,
Status: status,
PicId: req.PicId,
CreatedBy: actorID,
}
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil {
@@ -212,6 +240,7 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if err := common.EnsureRelations(c.Context(),
common.RelationCheck{Name: "Location", ID: req.LocationId, Exists: s.Repository.LocationExists},
common.RelationCheck{Name: "KandangGroup", ID: req.GroupId, Exists: s.Repository.KandangGroupExists},
common.RelationCheck{Name: "Pic", ID: req.PicId, Exists: s.Repository.PicExists},
); err != nil {
return nil, err
@@ -220,6 +249,16 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if req.LocationId != nil {
updateBody["location_id"] = *req.LocationId
}
if req.GroupId != nil {
targetLocationID := existing.LocationId
if req.LocationId != nil {
targetLocationID = *req.LocationId
}
if err := s.ensureKandangGroupAccess(c, *req.GroupId, &targetLocationID); err != nil {
return nil, err
}
updateBody["kandang_group_id"] = *req.GroupId
}
if req.PicId != nil {
updateBody["pic_id"] = *req.PicId
@@ -5,6 +5,7 @@ type Create struct {
Status string `json:"status,omitempty" validate:"omitempty,min=3,max=50"`
Capacity float64 `json:"capacity" validate:"required_strict,gt=0"`
LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"`
GroupId uint `json:"group_id" validate:"required_strict,number,gt=0"`
PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"`
ProjectFlockId *uint `json:"project_flock_id" validate:"omitempty,number,gt=0"`
}
@@ -14,6 +15,7 @@ type Update struct {
Status *string `json:"status,omitempty" validate:"omitempty,min=3,max=50"`
Capacity *float64 `json:"capacity" validate:"omitempty,gt=0"`
LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"`
GroupId *uint `json:"group_id" validate:"required_strict,number,gt=0"`
PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"`
ProjectFlockId *uint `json:"project_flock_id,omitempty" validate:"omitempty,number,gt=0"`
}
@@ -24,10 +24,11 @@ func NewLocationController(locationService service.LocationService) *LocationCon
func (u *LocationController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
AreaId: c.QueryInt("area_id", 0),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
AreaId: c.QueryInt("area_id", 0),
HasLaying: c.QueryBool("has_laying", false),
}
if query.Page < 1 || query.Limit < 1 {
@@ -60,6 +60,17 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
if params.AreaId != 0 {
db = db.Where("area_id = ?", params.AreaId)
}
if params.HasLaying {
db = db.Where(`
EXISTS (
SELECT 1
FROM project_flocks pf
WHERE pf.location_id = locations.id
AND pf.category = ?
AND pf.deleted_at IS NULL
)
`, utils.ProjectFlockCategoryLaying)
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
@@ -13,8 +13,9 @@ type Update struct {
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"`
Search string `query:"search" validate:"omitempty,max=50"`
AreaId int `query:"area_id" validate:"omitempty,number,gt=0"`
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"`
Search string `query:"search" validate:"omitempty,max=50"`
AreaId int `query:"area_id" validate:"omitempty,number,gt=0"`
HasLaying bool `query:"has_laying"`
}
@@ -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,17 @@ 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 {
includeAll := params.IncludeAll != nil && *params.IncludeAll
if params.IsDepletion != nil && *params.IsDepletion {
// Auto-expand visibility for depletion catalog so FE doesn't need include_all=true.
includeAll = true
}
// Default: show only visible products.
// include_all=true can be used to fetch all records (including hidden/system products).
// is_depletion, when provided, is composed as an additional flag filter.
if !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"`
}

Some files were not shown because too many files have changed in this diff Show More