Compare commits

...

60 Commits

Author SHA1 Message Date
Adnan Zahir 34c690956a fix: error checking id exists 2026-03-09 22:48: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
71 changed files with 2988 additions and 1195 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: workflow:
rules: rules:
# MR pipeline # run untuk branch utama & MR
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
when: always - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
# 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
- when: never - when: never
include: # =========================
# khusus MR (notif) # Helper: login ECR
- local: "ci/merge_request.yml" # =========================
rules: .ecr_login: &ecr_login |
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' 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 PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
- local: "ci/development.yml" ecr get-login-password --region "$AWS_REGION" || true)"
rules: if [ -z "$PASS" ]; then
- if: '$CI_COMMIT_BRANCH == "development"' 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: # MR
- if: '$CI_COMMIT_BRANCH == "staging"' # =========================
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: # DEVELOPMENT (push branch development)
- if: '$CI_COMMIT_BRANCH == "production"' # =========================
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 # 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 RUN apk add --no-cache git ca-certificates tzdata
WORKDIR /app WORKDIR /app
@@ -15,14 +15,17 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api 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 \ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-seed ./cmd/seed 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 # 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 \ RUN apk add --no-cache ca-certificates tzdata curl bash postgresql-client \
&& adduser -D -H -u 10001 appuser && 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-api /app/lti-api
COPY --from=builder /app/lti-seed /app/lti-seed 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 USER appuser
EXPOSE 8081 EXPOSE 8081
+1
View File
@@ -111,3 +111,4 @@ IT Development PT Mitra Berlian Unggas Group
## 📃 License ## 📃 License
> This project is private. All rights reserved. > 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: stages:
- deploy - build
- gitops
deploy-dev: variables:
stage: deploy AWS_REGION: ap-southeast-3
image: alpine:3.20 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: rules:
- if: '$CI_COMMIT_BRANCH == "development"' - if: '$CI_COMMIT_BRANCH == "development"'
when: on_success
- when: never
variables: variables:
DEPLOY_APP: "LTI-MBUGROUP" IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
before_script: before_script:
- echo "🧰 Installing dependencies..." - set -eu
- apk update && apk add --no-cache openssh git curl bash - docker version
- docker info
- *ecr_login
script: |
set -eu
echo "Build & push: $ECR_REPOSITORY:$IMAGE_TAG"
# Setup SSH di runner docker build \
- mkdir -p ~/.ssh -t "$ECR_REPOSITORY:$IMAGE_TAG" \
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa .
- chmod 600 ~/.ssh/id_rsa
- eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif docker push "$ECR_REPOSITORY:$IMAGE_TAG"
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
script: update_gitops_dev_lti:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" stage: gitops
- > image: public.ecr.aws/docker/library/alpine:3.20
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " tags: [self-hosted-dev]
set -e 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 add "$VALUES_FILE"
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git 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 # PROD
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts # =========================
# 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 # docker build \
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development # -t "$ECR_REPOSITORY:$IMAGE_TAG" \
git reset --hard origin/development # .
docker compose restart dev-api-lti || docker compose up -d dev-api-lti # docker push "$ECR_REPOSITORY:$IMAGE_TAG"
"; then
STATUS='success';
else
STATUS='failed';
fi;
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 # echo "Updating PROD image.tag to $IMAGE_TAG in $VALUES_FILE"
COLOR=3066993; # yq -i '.image.repository = strenv(ECR_REPOSITORY)' "$VALUES_FILE"
TITLE="✅ Deployment API Succeeded"; # yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
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 "{ # git add "$VALUES_FILE"
\"username\": \"CI Bot\", # if git diff --cached --quiet; then
\"embeds\": [{ # echo "No changes to commit"
\"title\": \"$TITLE\", # exit 0
\"description\": \"$DESC\", # fi
\"color\": $COLOR, # git commit -m "lti prod deploy ${IMAGE_TAG}"
\"fields\": [ # git push origin "$GITOPS_BRANCH"
{\"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
+3 -1
View File
@@ -57,6 +57,7 @@ var (
SSOPortalURL string SSOPortalURL string
SSOClients map[string]SSOClientConfig SSOClients map[string]SSOClientConfig
SSOAccessCookieName string SSOAccessCookieName string
SSOAccessCookieFallback []string
SSORefreshCookieName string SSORefreshCookieName string
SSOCookieDomain string SSOCookieDomain string
SSOCookieSecure bool SSOCookieSecure bool
@@ -107,7 +108,7 @@ func init() {
JWTResetPasswordExp = viper.GetInt("JWT_RESET_PASSWORD_EXP_MINUTES") JWTResetPasswordExp = viper.GetInt("JWT_RESET_PASSWORD_EXP_MINUTES")
JWTVerifyEmailExp = viper.GetInt("JWT_VERIFY_EMAIL_EXP_MINUTES") JWTVerifyEmailExp = viper.GetInt("JWT_VERIFY_EMAIL_EXP_MINUTES")
//Cors // Cors
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS") CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS") CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With") CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With")
@@ -147,6 +148,7 @@ func init() {
SSOGetMeURL = viper.GetString("SSO_GETME_URL") SSOGetMeURL = viper.GetString("SSO_GETME_URL")
SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL")) SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL"))
SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access") 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") SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh")
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE") 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{ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), Logger: logger.Default.LogMode(logger.Info),
SkipDefaultTransaction: true, SkipDefaultTransaction: true,
PrepareStmt: true, PrepareStmt: false,
TranslateError: true, TranslateError: true,
}) })
if err != nil { if err != nil {
@@ -1,250 +1,6 @@
BEGIN; BEGIN;
INSERT INTO fifo_stock_v2_flag_groups(code, name, priority) -- no-op: moved to 20260306090010_seed_fifo_stock_v2_config_after_core.up.sql
VALUES -- to ensure FIFO core tables exist before seeding on fresh migrations.
('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; COMMIT;
@@ -1,20 +1,5 @@
BEGIN; BEGIN;
-- Restore CHICKIN route if rollback is required. -- no-op: moved to 20260306090011_disable_chickin_fifo_consumption_after_core.down.sql
-- 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 $$;
COMMIT; COMMIT;
@@ -1,151 +1,6 @@
BEGIN; BEGIN;
-- Disable CHICKIN as FIFO USABLE so chick-in acts as business tagging/conversion, -- no-op: moved to 20260306090011_disable_chickin_fifo_consumption_after_core.up.sql
-- not physical stock consumption. -- to ensure FIFO core + seed are applied before this data update migration.
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; COMMIT;
@@ -1,9 +1,5 @@
BEGIN; BEGIN;
DELETE FROM fifo_stock_v2_route_rules -- no-op: moved to 20260306090012_fix_fifo_chickin_out_after_seed.down.sql
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT; COMMIT;
@@ -1,34 +1,6 @@
BEGIN; BEGIN;
INSERT INTO fifo_stock_v2_route_rules( -- no-op: moved to 20260306090012_fix_fifo_chickin_out_after_seed.up.sql
flag_group_code, -- to ensure AYAM flag group exists before route-rule upsert.
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; COMMIT;
@@ -1,7 +1,5 @@
BEGIN; BEGIN;
DELETE FROM fifo_stock_v2_flag_members -- no-op: moved to 20260306090013_add_ayam_flag_member_fifo_v2_after_seed.down.sql
WHERE flag_name = 'AYAM'
AND flag_group_code = 'AYAM';
COMMIT; COMMIT;
@@ -1,13 +1,6 @@
BEGIN; BEGIN;
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority, is_active, created_at, updated_at) -- no-op: moved to 20260306090013_add_ayam_flag_member_fifo_v2_after_seed.up.sql
VALUES -- to ensure AYAM flag group exists before inserting AYAM member.
('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; 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;
+1 -1
View File
@@ -17,7 +17,7 @@ type DailyChecklist struct {
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` 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"` Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;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"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
Employee Employee `gorm:"foreignKey:EmployeeId;references:Id"` Employee Employee `gorm:"foreignKey:EmployeeId;references:Id"`
Kandang Kandang `gorm:"foreignKey:KandangId;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"` 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"` Status string `gorm:"type:varchar(50);not null"`
LocationId uint `gorm:"not null"` LocationId uint `gorm:"not null"`
KandangGroupId uint `gorm:"not null"`
Capacity float64 `gorm:"not null"` Capacity float64 `gorm:"not null"`
PicId uint `gorm:"not null"` PicId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
@@ -19,6 +20,7 @@ type Kandang struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"`
KandangGroup KandangGroup `gorm:"foreignKey:KandangGroupId;references:Id"`
Pic User `gorm:"foreignKey:PicId;references:Id"` Pic User `gorm:"foreignKey:PicId;references:Id"`
Warehouses []Warehouse `gorm:"foreignKey:KandangId;references:Id"` Warehouses []Warehouse `gorm:"foreignKey:KandangId;references:Id"`
ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"` 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"`
}
+59 -13
View File
@@ -19,11 +19,11 @@ const (
// AuthContext keeps authentication details captured by the middleware. // AuthContext keeps authentication details captured by the middleware.
type AuthContext struct { type AuthContext struct {
Token string Token string
Verification *sso.VerificationResult Verification *sso.VerificationResult
User *entity.User User *entity.User
Roles []sso.Role Roles []sso.Role
Permissions map[string]struct{} Permissions map[string]struct{}
UserAreaIDs []uint UserAreaIDs []uint
UserLocationIDs []uint UserLocationIDs []uint
UserAllArea bool UserAllArea bool
@@ -36,8 +36,30 @@ type AuthContext struct {
func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
token := bearerToken(c) token := bearerToken(c)
if token == "" { tokenSource := ""
token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) 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 == "" { if token == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") 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) verification, err := sso.VerifyAccessToken(token)
if err != nil { 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") return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
} }
@@ -89,11 +115,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
} }
ctx := &AuthContext{ ctx := &AuthContext{
Token: token, Token: token,
Verification: verification, Verification: verification,
User: user, User: user,
Roles: roles, Roles: roles,
Permissions: permissions, Permissions: permissions,
UserAreaIDs: nil, UserAreaIDs: nil,
UserLocationIDs: nil, UserLocationIDs: nil,
UserAllArea: false, UserAllArea: false,
@@ -216,6 +242,26 @@ func hasAllScopes(have, required []string) bool {
return true 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. // RequirePermissions ensures the authenticated user possesses all specified permissions.
func RequirePermissions(perms ...string) fiber.Handler { func RequirePermissions(perms ...string) fiber.Handler {
required := canonicalPermissions(perms) required := canonicalPermissions(perms)
+2
View File
@@ -130,6 +130,8 @@ const (
P_KandangsUpdateOne = "lti.master.kandangs.update" P_KandangsUpdateOne = "lti.master.kandangs.update"
P_KandangsDeleteOne = "lti.master.kandangs.delete" P_KandangsDeleteOne = "lti.master.kandangs.delete"
P_KandangGroups = "lti.daily_checklist.master_data.kandang"
P_LocationsGetAll = "lti.master.locations.list" P_LocationsGetAll = "lti.master.locations.list"
P_LocationsGetOne = "lti.master.locations.detail" P_LocationsGetOne = "lti.master.locations.detail"
P_LocationsCreateOne = "lti.master.locations.create" P_LocationsCreateOne = "lti.master.locations.create"
@@ -1,8 +1,6 @@
package dto package dto
import ( import (
"encoding/json"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
) )
@@ -71,7 +69,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal
return dto 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) overheadsByNonstockID := make(map[uint]*OverheadDTO)
latestDateByNonstockID := make(map[uint]string) latestDateByNonstockID := make(map[uint]string)
@@ -113,35 +111,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex
qty := realizations[i].Qty qty := realizations[i].Qty
totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price) 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].ActualQuantity += qty
overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount 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) { func getItemInfo(nonstock *entity.Nonstock) (string, string) {
if nonstock != nil && nonstock.Id != 0 { if nonstock != nil && nonstock.Id != 0 {
return nonstock.Name, nonstock.Uom.Name return nonstock.Name, nonstock.Uom.Name
@@ -65,7 +65,7 @@ type SapronakCategoryRowDTO struct {
QtyOut float64 `json:"qty_out"` QtyOut float64 `json:"qty_out"`
QtyUsed float64 `json:"qty_used"` QtyUsed float64 `json:"qty_used"`
Description string `json:"description"` Description string `json:"description"`
ProductCategory []string `json:"product_category"` ProductCategory string `json:"product_category"`
UnitPrice float64 `json:"unit_price"` UnitPrice float64 `json:"unit_price"`
TotalAmount float64 `json:"total_amount"` TotalAmount float64 `json:"total_amount"`
Notes string `json:"notes"` Notes string `json:"notes"`
@@ -183,13 +183,13 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
"PULLET": 0, "PULLET": 0,
} }
buildFlagList := func(productID uint, fallback string) []string { buildFlagList := func(productID uint, fallback string) string {
rawFlags := productFlags[productID] rawFlags := productFlags[productID]
if len(rawFlags) == 0 { if len(rawFlags) == 0 {
if fallback == "" { if fallback == "" {
return []string{} return ""
} }
return []string{fallback} return fallback
} }
seen := make(map[string]struct{}, len(rawFlags)) seen := make(map[string]struct{}, len(rawFlags))
ordered := make([]string, 0, len(rawFlags)) ordered := make([]string, 0, len(rawFlags))
@@ -220,7 +220,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
return li < lj return li < lj
}) })
return ordered return strings.Join(ordered, " ")
} }
for _, group := range report.Groups { 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) { buildTotals := func(cat *SapronakCategoryDTO, label string) {
if cat == nil { if cat == nil {
return return
@@ -345,5 +366,22 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
buildTotals(result.Doc, "TOTAL DOC") buildTotals(result.Doc, "TOTAL DOC")
buildTotals(result.Ovk, "TOTAL OVK") buildTotals(result.Ovk, "TOTAL OVK")
buildTotals(result.Pakan, "TOTAL PAKAN") 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 return result
} }
@@ -25,17 +25,17 @@ type ClosingRepository interface {
SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error)
SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error)
GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error)
FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error)
FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error)
FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error)
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
} }
@@ -86,6 +86,8 @@ type SapronakQueryParams struct {
Limit int Limit int
Offset int Offset int
Search string Search string
StartDate *time.Time
EndDate *time.Time
} }
func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) {
@@ -142,15 +144,33 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
} }
var totalResults int64 var totalResults int64
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause) dateClause := ""
countArgs := append(append([]any{}, args...), searchArgs...) var dateArgs []any
if params.StartDate != nil {
dateClause += " AND sort_date::date >= ?"
dateArgs = append(dateArgs, params.StartDate)
}
if params.EndDate != nil {
dateClause += " AND sort_date::date <= ?"
dateArgs = append(dateArgs, params.EndDate)
}
whereClause := searchClause
if dateClause != "" {
if whereClause == "" {
whereClause = " WHERE " + strings.TrimPrefix(dateClause, " AND ")
} else {
whereClause += dateClause
}
}
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, whereClause)
countArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...)
if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil { if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
dataArgs := append(append([]any{}, args...), searchArgs...) dataArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...)
dataArgs = append(dataArgs, params.Limit, params.Offset) dataArgs = append(dataArgs, params.Limit, params.Offset)
dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause) dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, whereClause)
var rows []SapronakRow var rows []SapronakRow
if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil {
@@ -213,6 +233,25 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S
searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like) searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like)
} }
dateClause := ""
var dateArgs []any
if params.StartDate != nil {
dateClause += " AND sort_date::date >= ?"
dateArgs = append(dateArgs, params.StartDate)
}
if params.EndDate != nil {
dateClause += " AND sort_date::date <= ?"
dateArgs = append(dateArgs, params.EndDate)
}
whereClause := searchClause
if dateClause != "" {
if whereClause == "" {
whereClause = " WHERE " + strings.TrimPrefix(dateClause, " AND ")
} else {
whereClause += dateClause
}
}
querySQL := fmt.Sprintf(` querySQL := fmt.Sprintf(`
SELECT SELECT
product_category AS category, product_category AS category,
@@ -222,8 +261,8 @@ SELECT
FROM (%s) AS combined%s FROM (%s) AS combined%s
GROUP BY product_category, unit_id, unit GROUP BY product_category, unit_id, unit
ORDER BY product_category ASC, unit ASC ORDER BY product_category ASC, unit ASC
`, unionSQL, searchClause) `, unionSQL, whereClause)
queryArgs := append(append([]any{}, args...), searchArgs...) queryArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...)
var rows []SapronakSummaryRow var rows []SapronakSummaryRow
if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil { if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil {
@@ -778,6 +817,16 @@ type SapronakDetailRow struct {
func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) } func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) }
func applyDateRange(db *gorm.DB, column string, start, end *time.Time) *gorm.DB {
if start != nil {
db = db.Where(column+"::date >= ?", start)
}
if end != nil {
db = db.Where(column+"::date <= ?", end)
}
return db
}
func applyJoins(db *gorm.DB, joins ...string) *gorm.DB { func applyJoins(db *gorm.DB, joins ...string) *gorm.DB {
for _, j := range joins { for _, j := range joins {
if strings.TrimSpace(j) != "" { if strings.TrimSpace(j) != "" {
@@ -878,6 +927,14 @@ func (r *ClosingRepositoryImpl) fetchSapronakUsage(
return rows, nil return rows, nil
} }
func scanUsage(db *gorm.DB) ([]SapronakUsageRow, error) {
rows := make([]SapronakUsageRow, 0)
if err := db.Group("pw.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *ClosingRepositoryImpl) detailQuery( func (r *ClosingRepositoryImpl) detailQuery(
ctx context.Context, ctx context.Context,
table string, table string,
@@ -909,11 +966,11 @@ func (r *ClosingRepositoryImpl) fetchSapronakDetails(
return scanAndGroupDetails(r.detailQuery(ctx, table, pwJoinCond, joins, selectSQL, where, args...)) return scanAndGroupDetails(r.detailQuery(ctx, table, pwJoinCond, joins, selectSQL, where, args...))
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) {
if pfkID == 0 { if pfkID == 0 {
return nil, nil return nil, nil
} }
return r.fetchSapronakUsage( db := r.usageQuery(
ctx, ctx,
"recording_stocks rs", "recording_stocks rs",
"pw.id = rs.product_warehouse_id", "pw.id = rs.product_warehouse_id",
@@ -922,13 +979,15 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui
pfkID, pfkID,
sapronakFlagsUsage, sapronakFlagsUsage,
) )
db = applyDateRange(db, "r.record_datetime", start, end)
return scanUsage(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) {
if pfkID == 0 { if pfkID == 0 {
return []SapronakUsageRow{}, nil return []SapronakUsageRow{}, nil
} }
return r.fetchSapronakUsage( db := r.usageQuery(
ctx, ctx,
"project_chickins pc", "project_chickins pc",
"pw.id = pc.product_warehouse_id", "pw.id = pc.product_warehouse_id",
@@ -937,10 +996,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, p
pfkID, pfkID,
sapronakFlagsChickin, sapronakFlagsChickin,
) )
db = applyDateRange(db, "pc.chick_in_date", start, end)
return scanUsage(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
return r.fetchSapronakDetails( db := r.detailQuery(
ctx, ctx,
"recording_stocks rs", "recording_stocks rs",
"pw.id = rs.product_warehouse_id", "pw.id = rs.product_warehouse_id",
@@ -959,10 +1020,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p
pfkID, pfkID,
sapronakFlagsUsage, sapronakFlagsUsage,
) )
db = applyDateRange(db, "r.record_datetime", start, end)
return scanAndGroupDetails(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
return r.fetchSapronakDetails( db := r.detailQuery(
ctx, ctx,
"project_chickins pc", "project_chickins pc",
"pw.id = pc.product_warehouse_id", "pw.id = pc.product_warehouse_id",
@@ -981,13 +1044,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Con
pfkID, pfkID,
sapronakFlagsChickin, sapronakFlagsChickin,
) )
db = applyDateRange(db, "pc.chick_in_date", start, end)
return scanAndGroupDetails(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 { if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil return map[uint][]SapronakDetailRow{}, nil
} }
dateExpr := "COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime)"
query := r.withCtx(ctx). query := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
@@ -1029,18 +1095,19 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)"). Joins("LEFT JOIN product_warehouses pw_pc ON pw_pc.id = pc.product_warehouse_id").
Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw_pc.product_id, pw.product_id)").
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where(` Where(`
(sa.usable_type = ? AND r.project_flock_kandangs_id = ?) (sa.usable_type = ? AND r.project_flock_kandangs_id = ? AND f.name IN ?)
OR OR
(sa.usable_type = ? AND pc_used.project_flock_kandang_id = ?) (sa.usable_type = ? AND pc_used.project_flock_kandang_id = ? AND f.name IN ?)
`, `,
fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, sapronakFlagsUsage,
fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, sapronakFlagsChickin,
) )
query = r.joinSapronakProductFlag(query, "p_resolve"). query = r.joinSapronakProductFlag(query, "p_resolve").
Group(` Group(`
@@ -1049,11 +1116,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id, po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id,
pi.price, p_resolve.product_price pi.price, p_resolve.product_price
`) `)
query = applyDateRange(query, dateExpr, start, end)
return scanAndGroupDetails(query) return scanAndGroupDetails(query)
} }
func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint, start, end *time.Time) *gorm.DB {
db := r.withCtx(ctx). db := r.withCtx(ctx).
Table("purchase_items AS pi"). Table("purchase_items AS pi").
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
@@ -1062,12 +1130,13 @@ func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandan
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL") Where("pi.received_date IS NOT NULL")
db = applyDateRange(db, "pi.received_date", start, end)
return r.joinSapronakProductFlag(db, "p") return r.joinSapronakProductFlag(db, "p")
} }
func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) {
rows := make([]SapronakIncomingRow, 0) rows := make([]SapronakIncomingRow, 0)
db := r.incomingPurchaseBase(ctx, kandangID).Select(` db := r.incomingPurchaseBase(ctx, kandangID, start, end).Select(`
pi.product_id AS product_id, pi.product_id AS product_id,
p.name AS product_name, p.name AS product_name,
f.name AS flag, f.name AS flag,
@@ -1081,9 +1150,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kanda
return rows, nil return rows, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
return scanAndGroupDetails( return scanAndGroupDetails(
r.incomingPurchaseBase(ctx, kandangID).Select(` r.incomingPurchaseBase(ctx, kandangID, start, end).Select(`
pi.product_id AS product_id, pi.product_id AS product_id,
p.name AS product_name, p.name AS product_name,
f.name AS flag, f.name AS flag,
@@ -1178,7 +1247,7 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
return in, out return in, out
} }
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
poByWarehouse := r.DB(). poByWarehouse := r.DB().
Table("purchase_items pi"). Table("purchase_items pi").
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date"). Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date").
@@ -1205,11 +1274,13 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("COALESCE(ast.total_qty, 0) > 0") Where("COALESCE(ast.total_qty, 0) > 0")
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incomingQuery = applyDateRange(incomingQuery, "ast.created_at", start, end)
incoming, err := scanAndGroupDetails(incomingQuery) incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
dateExpr := "COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at)"
outgoingQuery := r.withCtx(ctx). outgoingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
@@ -1243,6 +1314,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end)
outgoing, err := scanAndGroupDetails(outgoingQuery) outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1251,7 +1323,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
return incoming, outgoing, nil return incoming, outgoing, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
incomingQuery := r.withCtx(ctx). incomingQuery := r.withCtx(ctx).
Table("stock_transfer_details AS std"). Table("stock_transfer_details AS std").
Select(` Select(`
@@ -1273,6 +1345,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)"). Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll) Where("f.name IN ?", sapronakFlagsAll)
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incomingQuery = applyDateRange(incomingQuery, "st.transfer_date", start, end)
incoming, err := scanAndGroupDetails(incomingQuery) incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1300,6 +1373,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)"). Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll) Where("f.name IN ?", sapronakFlagsAll)
incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p") incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p")
incomingLayingQuery = applyDateRange(incomingLayingQuery, "lt.transfer_date", start, end)
incomingLaying, err := scanAndGroupDetails(incomingLayingQuery) incomingLaying, err := scanAndGroupDetails(incomingLayingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1334,6 +1408,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoingQuery = applyDateRange(outgoingQuery, "st.transfer_date", start, end)
outgoing, err := scanAndGroupDetails(outgoingQuery) outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1365,6 +1440,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Group("lt.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") Group("lt.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price")
outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p") outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p")
outgoingLayingQuery = applyDateRange(outgoingLayingQuery, "lt.transfer_date", start, end)
outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1376,7 +1452,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
return incoming, outgoing, nil return incoming, outgoing, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
query := r.withCtx(ctx). query := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
@@ -1401,6 +1477,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
query = r.joinSapronakProductFlag(query, "p") query = r.joinSapronakProductFlag(query, "p")
query = applyDateRange(query, "COALESCE(mdp.delivery_date, mdp.created_at)", start, end)
sales, err := scanAndGroupDetails(query) sales, err := scanAndGroupDetails(query)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1434,6 +1511,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p") nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p")
nonFifoQuery = applyDateRange(nonFifoQuery, "COALESCE(mdp.delivery_date, mdp.created_at)", start, end)
nonFifoSales, err := scanAndGroupDetails(nonFifoQuery) nonFifoSales, err := scanAndGroupDetails(nonFifoQuery)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1446,57 +1524,114 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
return sales, nil return sales, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 { if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil return map[uint][]SapronakDetailRow{}, nil
} }
query := r.withCtx(ctx). pfpType := fifo.StockableKeyProjectFlockPopulation.String()
Table("stock_allocations AS sa"). dateExpr := fmt.Sprintf(`
Select(` CASE
pw.product_id AS product_id, WHEN sa.stockable_type = '%s' THEN COALESCE(
p.name AS product_name, pi_pc.received_date,
f.name AS flag, st_pc.transfer_date,
COALESCE( lt_pc.transfer_date,
ast_pc.created_at,
pc.chick_in_date
)
ELSE COALESCE(
pi.received_date, pi.received_date,
st.transfer_date, st.transfer_date,
lt.transfer_date, lt.transfer_date,
ast.created_at ast.created_at
) AS date, )
COALESCE( END
po.po_number, `, pfpType)
st.movement_number,
lt.transfer_number, query := r.withCtx(ctx).
CONCAT('ADJ-', ast.id), Table("stock_allocations AS sa").
'' Select(fmt.Sprintf(`
) AS reference, p_resolve.id AS product_id,
p_resolve.name AS product_name,
f.name AS flag,
CASE
WHEN sa.stockable_type = '%s' THEN COALESCE(
pi_pc.received_date,
st_pc.transfer_date,
lt_pc.transfer_date,
ast_pc.created_at,
pc.chick_in_date
)
ELSE COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
ast.created_at
)
END AS date,
CASE
WHEN sa.stockable_type = '%s' THEN COALESCE(
po_pc.po_number,
st_pc.movement_number,
lt_pc.transfer_number,
CASE WHEN ast_pc.id IS NOT NULL THEN CONCAT('ADJ-', ast_pc.id) END,
CONCAT('CHICKIN-', pc.id),
''
)
ELSE COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
CASE WHEN ast.id IS NOT NULL THEN CONCAT('ADJ-', ast.id) END,
''
)
END AS reference,
0 AS qty_in, 0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out, COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p.product_price, 0) AS price CASE
`). WHEN sa.stockable_type = '%s' THEN COALESCE(pi_pc.price, p_resolve.product_price, 0)
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). ELSE COALESCE(pi.price, p_resolve.product_price, 0)
Joins("JOIN products p ON p.id = pw.product_id"). END AS price
`, pfpType, pfpType, pfpType)).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()). Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()).
Joins("JOIN product_warehouses pw_sales ON pw_sales.id = mdp.product_warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Joins("LEFT JOIN stock_allocations sa_pc ON sa_pc.usable_type = ? AND sa_pc.usable_id = pc.id", fifo.UsableKeyProjectChickin.String()).
Joins("LEFT JOIN purchase_items pi_pc ON pi_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po_pc ON po_pc.id = pi_pc.purchase_id").
Joins("LEFT JOIN stock_transfer_details std_pc ON std_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st_pc ON st_pc.id = std_pc.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt_pc ON ltt_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt_pc ON lt_pc.id = ltt_pc.laying_transfer_id").
Joins("LEFT JOIN adjustment_stocks ast_pc ON ast_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN product_warehouses pw_pc ON pw_pc.id = pc.product_warehouse_id").
Joins(fmt.Sprintf("LEFT JOIN products p_resolve ON p_resolve.id = CASE WHEN sa.stockable_type = '%s' THEN pw_pc.product_id ELSE COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id) END", pfpType)).
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Group(` Group(`
pw.product_id, p.name, f.name, p_resolve.id, p_resolve.name, f.name,
pi_pc.received_date, st_pc.transfer_date, lt_pc.transfer_date, ast_pc.created_at, pc.chick_in_date,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at,
po_pc.po_number, st_pc.movement_number, lt_pc.transfer_number, ast_pc.id, pc.id,
po.po_number, st.movement_number, lt.transfer_number, ast.id, po.po_number, st.movement_number, lt.transfer_number, ast.id,
pi.price, p.product_price pi_pc.price, pi.price, p_resolve.product_price, sa.stockable_type
`) `)
query = r.joinSapronakProductFlag(query, "p") query = r.joinSapronakProductFlag(query, "p_resolve")
query = applyDateRange(query, dateExpr, start, end)
return scanAndGroupDetails(query) return scanAndGroupDetails(query)
} }
@@ -1510,7 +1645,6 @@ func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, p
Preload("Flags"). Preload("Flags").
Where("id IN ?", productIDs). Where("id IN ?", productIDs).
Find(&products).Error Find(&products).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -2,7 +2,6 @@ package service
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math" "math"
@@ -33,6 +32,14 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type activeKandangMetric struct {
ProjectFlockKandangID uint
ProjectFlockID uint
KandangID uint
Category string
Metric float64
}
type ClosingService interface { type ClosingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error)
GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
@@ -385,6 +392,11 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
} }
offset := (params.Page - 1) * params.Limit 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{ rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{
Type: params.Type, Type: params.Type,
WarehouseIDs: warehouseIDs, WarehouseIDs: warehouseIDs,
@@ -392,6 +404,8 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
Limit: params.Limit, Limit: params.Limit,
Offset: offset, Offset: offset,
Search: params.Search, Search: params.Search,
StartDate: startDate,
EndDate: endDate,
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, 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{ rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{
Type: params.Type, Type: params.Type,
WarehouseIDs: warehouseIDs, WarehouseIDs: warehouseIDs,
ProjectFlockKandangIDs: projectFlockKandangIDs, ProjectFlockKandangIDs: projectFlockKandangIDs,
Search: params.Search, Search: params.Search,
StartDate: startDate,
EndDate: endDate,
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err) 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 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 { func formatQuantity(qty float64, uom string) string {
qtyStr := strconv.FormatFloat(qty, 'f', -1, 64) qtyStr := strconv.FormatFloat(qty, 'f', -1, 64)
if uom == "" { if uom == "" {
@@ -616,38 +722,17 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
return nil, err 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) projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totalKandangCount := len(projectFlockKandangs) 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) chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -688,11 +773,197 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
totalActualPopulation := totalChickinQty - totalDepletion 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 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) { func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) {
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil { if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -123,7 +124,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
continue 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) items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag)
if err != nil { if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) 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) { 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 // Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any).
// and aggregate all historical transactions for the kandang/project. startDate, endDate := sapronakPeriodRange(pfk)
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId) incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err 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 { if err != nil {
return nil, nil, 0, 0, err 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 { if err != nil {
return nil, nil, 0, 0, err 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 { if err != nil {
return nil, nil, 0, 0, err 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 { if err != nil {
return nil, nil, 0, 0, err 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 { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
@@ -413,15 +414,15 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
usageDetailsRows = usageAllocatedDetails usageDetailsRows = usageAllocatedDetails
chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{} 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 { if err != nil {
return nil, nil, 0, 0, err 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 { if err != nil {
return nil, nil, 0, 0, err 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 { if err != nil {
return nil, nil, 0, 0, err 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 // should not be counted yet. Only when category is LAYING we allow
// pullet usage to contribute to qty_used. // pullet usage to contribute to qty_used.
isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying)) isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying))
hasChickin := len(pfk.Chickins) > 0
if !isLaying { if !isLaying {
filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows)) filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows))
@@ -491,11 +493,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
chickinUsageDetailsRows = filteredDetail 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 { for pid, rows := range chickinUsageDetailsRows {
if len(rows) == 0 { if len(rows) == 0 {
continue continue
@@ -512,6 +509,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
transOutgoing := detailMaps.TransferOut transOutgoing := detailMaps.TransferOut
salesOutgoing := detailMaps.SalesOut 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) transIncoming = dedupTransfers(transIncoming)
transOutgoing = dedupTransfers(transOutgoing) transOutgoing = dedupTransfers(transOutgoing)
@@ -775,6 +777,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if !matchesFlag(flag) { if !matchesFlag(flag) {
continue continue
} }
if hasChickin && (strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER")) {
continue
}
group := ensureGroup(flag) group := ensureGroup(flag)
for _, d := range details { for _, d := range details {
if d.Flag == "" { if d.Flag == "" {
@@ -794,6 +799,10 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if !matchesFlag(flag) { if !matchesFlag(flag) {
continue 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) group := ensureGroup(flag)
for _, d := range details { for _, d := range details {
if d.Flag == "" { if d.Flag == "" {
@@ -815,3 +824,20 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
return items, groups, totalIncoming, totalUsage, nil 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 status = *item.Status
} }
var kandang *kandangDTO.KandangRelationDTO // var kandang *kandangDTO.KandangRelationDTO
var kandang *kandangDTO.KandangGroupRelationDTO
if item.Kandang.Id != 0 { if item.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(item.Kandang) // mapped := kandangDTO.ToKandangRelationDTO(item.Kandang)x
mapped := kandangDTO.ToKandangGroupRelationDTO(item.Kandang)
kandang = &mapped kandang = &mapped
} }
@@ -19,19 +19,19 @@ type DailyChecklistRelationDTO struct {
} }
type DailyChecklistListDTO struct { type DailyChecklistListDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
Category string `json:"category"` Category string `json:"category"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` Kandang *kandangDTO.KandangGroupRelationDTO `json:"kandang,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
TotalPhase int `json:"total_phase"` TotalPhase int `json:"total_phase"`
TotalActivity int `json:"total_activity"` TotalActivity int `json:"total_activity"`
Progress int `json:"progress"` Progress int `json:"progress"`
RejectReason *string `json:"reject_reason"` RejectReason *string `json:"reject_reason"`
} }
type DailyChecklistDetailDTO struct { type DailyChecklistDetailDTO struct {
@@ -156,9 +156,9 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO {
status = *e.Status status = *e.Status
} }
var kandang *kandangDTO.KandangRelationDTO var kandang *kandangDTO.KandangGroupRelationDTO
if e.Kandang.Id != 0 { if e.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(e.Kandang) mapped := kandangDTO.ToKandangGroupRelationDTO(e.Kandang)
kandang = &mapped kandang = &mapped
} }
@@ -75,7 +75,7 @@ type DailyChecklistListItem struct {
RejectReason *string RejectReason *string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
Kandang entity.Kandang Kandang entity.KandangGroup
TotalPhase int TotalPhase int
TotalActivity int TotalActivity int
Progress int Progress int
@@ -142,7 +142,7 @@ func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID u
db := s.Repository.DB().WithContext(c.Context()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc"). 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 locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id = ?", checklistID) Where("dc.id = ?", checklistID)
@@ -168,7 +168,7 @@ func (s dailyChecklistService) ensureKandangAccess(c *fiber.Ctx, kandangID uint)
} }
db := s.Repository.DB().WithContext(c.Context()). 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 locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
Where("k.id = ?", kandangID) 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()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_tasks t"). Table("daily_checklist_activity_tasks t").
Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id"). 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 locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
Where("t.id = ?", taskID) 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()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc"). 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 locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id") Joins("JOIN areas a ON a.id = loc.area_id")
@@ -341,9 +341,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 { if len(kandangIDs) > 0 {
var kandangs []entity.Kandang var kandangs []entity.KandangGroup
if err := s.Repository.DB().WithContext(c.Context()). if err := s.Repository.DB().WithContext(c.Context()).
Where("id IN ?", kandangIDs). Where("id IN ?", kandangIDs).
Preload("Location"). Preload("Location").
@@ -1019,7 +1019,7 @@ func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.Summa
MAX(a.updated_at) AS last_activity`). MAX(a.updated_at) AS last_activity`).
Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id"). 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 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 employees e ON e.id = a.employee_id").
Joins("JOIN locations loc ON loc.id = k.location_id"). Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas ar ON ar.id = loc.area_id"). Joins("JOIN areas ar ON ar.id = loc.area_id").
@@ -1092,7 +1092,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_checklist_activity_tasks dcat ON dcat.id = dca.task_id").
Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id"). Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id").
Joins("JOIN employees e ON e.id = dca.employee_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 locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
Joins("JOIN phases p ON p.id = dcat.phase_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 expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id").
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_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 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.realization_date IS NOT NULL").
Where("expenses.category = ?", "BOP") Where("expenses.category = ?", "BOP")
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
db = db.Where(`( db = db.Where(`(
expense_nonstocks.project_flock_kandang_id = ? OR 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) (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 { } else {
db = db.Where(`( db = db.Where(`(
project_flock_kandangs.project_flock_id = ? OR 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) (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 err := db.Find(&realizations).Error
@@ -739,24 +739,25 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
} }
offset := (query.Page - 1) * query.Limit offset := (query.Page - 1) * query.Limit
var isProductsExist bool if query.WarehouseID > 0 {
isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) isWarehouseExist, err := s.WarehouseRepo.IdExists(c.Context(), query.WarehouseID)
if err != nil {
if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse")
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") }
} if !isWarehouseExist {
if query.WarehouseID > 0 && !isWarehousesExist { return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") }
} }
isProductsExist, err = s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) if query.ProductID > 0 {
isProductExist, err := s.ProductRepo.IdExists(c.Context(), query.ProductID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to check product existence: %+v", err) s.Log.Errorf("Failed to check product existence: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product")
} }
if query.ProductID > 0 && !isProductsExist { if !isProductExist {
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found")
}
} }
scope, scopeErr := m.ResolveLocationScope(c, s.AdjustmentStockRepository.DB()) scope, scopeErr := m.ResolveLocationScope(c, s.AdjustmentStockRepository.DB())
@@ -127,18 +127,30 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
Preload("Products.DeliveryProduct") Preload("Products.DeliveryProduct")
if params.Status != "" { if params.Status != "" {
status := strings.TrimSpace(params.Status)
latestApprovalSubQuery := s.MarketingRepo.DB(). latestApprovalSubQuery := s.MarketingRepo.DB().
WithContext(c.Context()). WithContext(c.Context()).
Table("approvals"). 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()). Where("approvable_type = ?", utils.ApprovalWorkflowMarketing.String()).
Order("approvable_id, id DESC") Order("approvable_id, id DESC")
db = db.Where(`EXISTS (
SELECT 1 if strings.EqualFold(status, "DITOLAK") {
FROM (?) AS latest_approval db = db.Where(`EXISTS (
WHERE latest_approval.approvable_id = marketings.id SELECT 1
AND LOWER(latest_approval.step_name) = LOWER(?) FROM (?) AS latest_approval
)`, latestApprovalSubQuery, params.Status) 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 != "" { 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") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -258,7 +269,6 @@ func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDeta
return db.Preload("ActionUser") return db.Preload("ActionUser")
}) })
if err != nil { if err != nil {
} else if len(approvals) > 0 { } else if len(approvals) > 0 {
if marketing.LatestApproval == nil { if marketing.LatestApproval == nil {
latest := approvals[len(approvals)-1] 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 { err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(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 deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber
if requestedProduct.Qty > 0 { if requestedProduct.Qty > 0 {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil { if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil {
return err return err
} }
@@ -404,7 +412,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
return nil return nil
}) })
if err != nil { if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok { if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr 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 { err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction) marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction)
@@ -525,7 +531,6 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil return nil
}) })
if err != nil { if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok { if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr return nil, fiberErr
@@ -558,6 +563,12 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
if deliveryProduct == nil || deliveryProduct.Id == 0 { if deliveryProduct == nil || deliveryProduct.Id == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") 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) deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
previousUsage := deliveryProduct.UsageQty previousUsage := deliveryProduct.UsageQty
@@ -595,12 +606,12 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
Decrease: allocatedDelta, Decrease: allocatedDelta,
LoggableType: string(utils.StockLogTypeMarketing), LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id, LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId, ProductWarehouseId: deliveryProduct.ProductWarehouseId,
CreatedBy: actorID, CreatedBy: actorID,
Notes: fmt.Sprintf("FIFO v2 reflow consume (%.2f)", allocatedDelta), 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
} }
@@ -151,6 +151,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) soDate, err := utils.ParseDateString(req.Date)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
@@ -4,7 +4,7 @@ import (
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" 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 === // === DTO Structs ===
@@ -15,12 +15,12 @@ type EmployeesRelationDTO struct {
} }
type EmployeesListDTO struct { type EmployeesListDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
Kandangs []kandangDTO.KandangRelationDTO `json:"kandangs"` Kandangs []kandangGroupDTO.KandangGroupRelationDTO `json:"kandangs"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type EmployeesDetailDTO struct { type EmployeesDetailDTO struct {
@@ -37,12 +37,12 @@ func ToEmployeesRelationDTO(e entity.Employees) EmployeesRelationDTO {
} }
func ToEmployeesListDTO(e entity.Employees) EmployeesListDTO { 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 { for _, rel := range e.EmployeeKandangs {
if rel.Kandang.Id == 0 { if rel.Kandang.Id == 0 {
continue continue
} }
kandangs = append(kandangs, kandangDTO.ToKandangRelationDTO(rel.Kandang)) kandangs = append(kandangs, kandangGroupDTO.ToKandangGroupRelationDTO(rel.Kandang))
} }
return EmployeesListDTO{ return EmployeesListDTO{
@@ -52,7 +52,7 @@ func (s employeesService) ensureEmployeeAccess(c *fiber.Ctx, employeeID uint) er
db := s.Repository.DB().WithContext(c.Context()). db := s.Repository.DB().WithContext(c.Context()).
Table("employees e"). Table("employees e").
Joins("JOIN employee_kandangs ek ON ek.employee_id = e.id"). 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 locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
Where("e.id = ?", employeeID). Where("e.id = ?", employeeID).
@@ -79,7 +79,7 @@ func (s employeesService) ensureKandangIDsAccess(c *fiber.Ctx, kandangIDs []uint
} }
db := s.Repository.DB().WithContext(c.Context()). 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 locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id"). Joins("JOIN areas a ON a.id = loc.area_id").
Where("k.id IN ?", kandangIDs) 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 { employeess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db = db.Joins("JOIN employee_kandangs ek ON ek.employee_id = employees.id"). 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 locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id") Joins("JOIN areas a ON a.id = loc.area_id")
var scopeErr error 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,41 @@
package repository
import (
"context"
"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)
}
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)
}
@@ -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,242 @@
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
}
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 === // === DTO Structs ===
type KandangRelationDTO struct { type KandangRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
Capacity float64 `json:"capacity"` Capacity float64 `json:"capacity"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` KandangGroup *KandangGroupRelationDTO `json:"kandang_group,omitempty"`
Pic *userDTO.UserRelationDTO `json:"pic,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 { type KandangListDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
Capacity float64 `json:"capacity"` Capacity float64 `json:"capacity"`
Location locationDTO.LocationRelationDTO `json:"location"` KandangGroup KandangGroupRelationDTO `json:"kandang_group"`
Pic userDTO.UserRelationDTO `json:"pic"` Location locationDTO.LocationRelationDTO `json:"location"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"` Pic userDTO.UserRelationDTO `json:"pic"`
CreatedAt time.Time `json:"created_at"` CreatedUser userDTO.UserRelationDTO `json:"created_user"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type KandangDetailDTO struct { type KandangDetailDTO struct {
@@ -38,6 +46,12 @@ type KandangDetailDTO struct {
// === Mapper Functions === // === Mapper Functions ===
func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO { func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO {
var kandangGroup *KandangGroupRelationDTO
if e.KandangGroup.Id != 0 {
mapped := ToKandangGroupRelationDTO(e.KandangGroup)
kandangGroup = &mapped
}
var location *locationDTO.LocationRelationDTO var location *locationDTO.LocationRelationDTO
if e.Location.Id != 0 { if e.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(e.Location) mapped := locationDTO.ToLocationRelationDTO(e.Location)
@@ -51,16 +65,31 @@ func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO {
} }
return KandangRelationDTO{ return KandangRelationDTO{
Id: e.Id, Id: e.Id,
Name: e.Name, Name: e.Name,
Status: e.Status, Status: e.Status,
Capacity: e.Capacity, Capacity: e.Capacity,
Location: location, KandangGroup: kandangGroup,
Pic: pic, 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 { func ToKandangListDTO(e entity.Kandang) KandangListDTO {
var kandangGroup KandangGroupRelationDTO
if e.KandangGroup.Id != 0 {
mapped := ToKandangGroupRelationDTO(e.KandangGroup)
kandangGroup = mapped
}
var location locationDTO.LocationRelationDTO var location locationDTO.LocationRelationDTO
if e.Location.Id != 0 { if e.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(e.Location) mapped := locationDTO.ToLocationRelationDTO(e.Location)
@@ -80,15 +109,16 @@ func ToKandangListDTO(e entity.Kandang) KandangListDTO {
} }
return KandangListDTO{ return KandangListDTO{
Id: e.Id, Id: e.Id,
Name: e.Name, Name: e.Name,
Status: e.Status, Status: e.Status,
Location: location, Location: location,
Capacity: e.Capacity, KandangGroup: kandangGroup,
Pic: pic, Capacity: e.Capacity,
CreatedAt: e.CreatedAt, Pic: pic,
UpdatedAt: e.UpdatedAt, CreatedAt: e.CreatedAt,
CreatedUser: createdUser, UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser,
} }
} }
@@ -4,10 +4,10 @@ import (
"context" "context"
"errors" "errors"
"github.com/gofiber/fiber/v2"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -15,6 +15,7 @@ type KandangRepository interface {
repository.BaseRepository[entity.Kandang] repository.BaseRepository[entity.Kandang]
LocationExists(ctx context.Context, areaId uint) (bool, error) LocationExists(ctx context.Context, areaId uint) (bool, error)
PicExists(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) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
ProjectFlockExists(ctx context.Context, projectFlockID uint) (bool, error) ProjectFlockExists(ctx context.Context, projectFlockID uint) (bool, error)
GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, 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) 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) { func (r *KandangRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) {
return repository.ExistsByName[entity.Kandang](ctx, r.db, name, excludeID) return repository.ExistsByName[entity.Kandang](ctx, r.db, name, excludeID)
} }
@@ -3,13 +3,14 @@ package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
common "gitlab.com/mbugroup/lti-api.git/internal/common/service" common "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "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 { 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 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) { func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Kandang, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
@@ -116,10 +139,14 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
if err := common.EnsureRelations(c.Context(), if err := common.EnsureRelations(c.Context(),
common.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists}, 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}, common.RelationCheck{Name: "Pic", ID: &req.PicId, Exists: s.Repository.PicExists},
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if err := s.ensureKandangGroupAccess(c, req.GroupId, &req.LocationId); err != nil {
return nil, err
}
status := strings.ToUpper(strings.TrimSpace(req.Status)) status := strings.ToUpper(strings.TrimSpace(req.Status))
if status == "" { if status == "" {
@@ -154,12 +181,13 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
} }
createBody := &entity.Kandang{ createBody := &entity.Kandang{
Name: req.Name, Name: req.Name,
LocationId: req.LocationId, LocationId: req.LocationId,
Capacity: req.Capacity, KandangGroupId: req.GroupId,
Status: status, Capacity: req.Capacity,
PicId: req.PicId, Status: status,
CreatedBy: actorID, PicId: req.PicId,
CreatedBy: actorID,
} }
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { 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(), if err := common.EnsureRelations(c.Context(),
common.RelationCheck{Name: "Location", ID: req.LocationId, Exists: s.Repository.LocationExists}, 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}, common.RelationCheck{Name: "Pic", ID: req.PicId, Exists: s.Repository.PicExists},
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -220,6 +249,16 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if req.LocationId != nil { if req.LocationId != nil {
updateBody["location_id"] = *req.LocationId 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 { if req.PicId != nil {
updateBody["pic_id"] = *req.PicId updateBody["pic_id"] = *req.PicId
@@ -5,6 +5,7 @@ type Create struct {
Status string `json:"status,omitempty" validate:"omitempty,min=3,max=50"` Status string `json:"status,omitempty" validate:"omitempty,min=3,max=50"`
Capacity float64 `json:"capacity" validate:"required_strict,gt=0"` Capacity float64 `json:"capacity" validate:"required_strict,gt=0"`
LocationId uint `json:"location_id" validate:"required_strict,number,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"` PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"`
ProjectFlockId *uint `json:"project_flock_id" validate:"omitempty,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"` Status *string `json:"status,omitempty" validate:"omitempty,min=3,max=50"`
Capacity *float64 `json:"capacity" validate:"omitempty,gt=0"` Capacity *float64 `json:"capacity" validate:"omitempty,gt=0"`
LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,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"` PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"`
ProjectFlockId *uint `json:"project_flock_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 { func (u *LocationController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: c.Query("search", ""),
AreaId: c.QueryInt("area_id", 0), AreaId: c.QueryInt("area_id", 0),
HasLaying: c.QueryBool("has_laying", false),
} }
if query.Page < 1 || query.Limit < 1 { 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 { if params.AreaId != 0 {
db = db.Where("area_id = ?", params.AreaId) 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") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
@@ -13,8 +13,9 @@ type Update struct {
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` 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 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 { if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
@@ -229,9 +229,9 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
// Depletion master products are system products and often stored with is_visible = false. // Default: show only visible products.
// When requested explicitly via is_depletion=true, include hidden records. // include_all=true can be used to fetch all records (including hidden/system products).
if params.IsDepletion == nil || !*params.IsDepletion { if params.IncludeAll == nil || !*params.IncludeAll {
db = db.Where("is_visible = ?", true) db = db.Where("is_visible = ?", true)
} }
if params.Search != "" { if params.Search != "" {
@@ -45,4 +45,5 @@ type Query struct {
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"` ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"`
IsDepletion *bool `query:"is_depletion" validate:"omitempty"` IsDepletion *bool `query:"is_depletion" validate:"omitempty"`
IncludeAll *bool `query:"include_all" validate:"omitempty"`
} }
+3 -1
View File
@@ -9,10 +9,12 @@ import (
areas "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas" areas "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas"
banks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks" banks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks"
configChecklists "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists"
customers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers" customers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers"
employeess "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees" employeess "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees"
fcrs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs" fcrs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs"
flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks" flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks"
kandanggroups "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandang-groups"
kandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs" kandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs"
locations "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations" locations "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations"
nonstocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks" nonstocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks"
@@ -24,7 +26,6 @@ import (
suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers"
uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms"
warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses"
configChecklists "gitlab.com/mbugroup/lti-api.git/internal/modules/master/config-checklists"
// MODULE IMPORTS // MODULE IMPORTS
) )
@@ -35,6 +36,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida
uoms.UomModule{}, uoms.UomModule{},
areas.AreaModule{}, areas.AreaModule{},
locations.LocationModule{}, locations.LocationModule{},
kandanggroups.KandangGroupModule{},
kandangs.KandangModule{}, kandangs.KandangModule{},
warehouses.WarehouseModule{}, warehouses.WarehouseModule{},
customers.CustomerModule{}, customers.CustomerModule{},
@@ -308,25 +308,23 @@ func (s projectFlockKandangService) CheckClosing(c *fiber.Ctx, id uint) (*Closin
} }
for _, pw := range productWarehouses { for _, pw := range productWarehouses {
if pw.Quantity > 0 { category := ""
category := "" if pw.Product.ProductCategory.Id != 0 {
if pw.Product.ProductCategory.Id != 0 { category = pw.Product.ProductCategory.Name
category = pw.Product.ProductCategory.Name
}
uomName := ""
if pw.Product.Uom.Id != 0 {
uomName = pw.Product.Uom.Name
}
stockRemain = append(stockRemain, StockRemainingDetail{
FlagName: string(flagName),
ProductWarehouseId: pw.Id,
ProductId: pw.ProductId,
ProductName: pw.Product.Name,
ProductCategory: category,
Uom: uomName,
Quantity: pw.Quantity,
})
} }
uomName := ""
if pw.Product.Uom.Id != 0 {
uomName = pw.Product.Uom.Name
}
stockRemain = append(stockRemain, StockRemainingDetail{
FlagName: string(flagName),
ProductWarehouseId: pw.Id,
ProductId: pw.ProductId,
ProductName: pw.Product.Name,
ProductCategory: category,
Uom: uomName,
Quantity: pw.Quantity,
})
} }
} }
} }
@@ -585,7 +583,7 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati
} }
} }
if s.ApprovalSvc != nil { if s.ApprovalSvc != nil {
reopenAction := entity.ApprovalActionUpdated reopenAction := entity.ApprovalActionApproved
// Hindari duplikasi jika approval terakhir sudah Disetujui + Updated // Hindari duplikasi jika approval terakhir sudah Disetujui + Updated
latestPFK, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) latestPFK, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil)
if lerr != nil { if lerr != nil {
@@ -611,6 +609,31 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati
return nil, aerr return nil, aerr
} }
} }
// Pastikan approval project flock kembali ke Aktif
latestPF, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlock, pfk.ProjectFlockId, nil)
if lerr != nil {
return nil, lerr
}
shouldCreatePF := true
if latestPF != nil &&
latestPF.StepNumber == uint16(utils.ProjectFlockStepAktif) &&
latestPF.Action != nil && *latestPF.Action == reopenAction {
shouldCreatePF = false
}
if shouldCreatePF {
if _, aerr := s.ApprovalSvc.CreateApproval(
c.Context(),
utils.ApprovalWorkflowProjectFlock,
pfk.ProjectFlockId,
utils.ProjectFlockStepAktif,
&reopenAction,
actorID,
nil,
); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) {
return nil, aerr
}
}
} }
default: default:
return nil, fiber.NewError(fiber.StatusBadRequest, "action harus close atau unclose") return nil, fiber.NewError(fiber.StatusBadRequest, "action harus close atau unclose")
@@ -42,6 +42,7 @@ type KandangWithProjectFlockIdDTO struct {
kandangDTO.KandangRelationDTO kandangDTO.KandangRelationDTO
ProjectFlockKandangId uint `json:"project_flock_kandang_id"` ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
Period int `json:"period"` Period int `json:"period"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
} }
type ProjectFlockDetailDTO struct { type ProjectFlockDetailDTO struct {
@@ -74,20 +75,28 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF
for i, kandang := range e.Kandangs { for i, kandang := range e.Kandangs {
var ( var (
pfkId uint pfkId uint
period int period int
closedAt *time.Time
) )
for _, kh := range e.KandangHistory { for _, kh := range e.KandangHistory {
if kh.KandangId == kandang.Id { if kh.KandangId == kandang.Id {
pfkId = kh.Id pfkId = kh.Id
period = kh.Period period = kh.Period
closedAt = kh.ClosedAt
break break
} }
} }
mapped := kandangDTO.ToKandangRelationDTO(kandang)
if closedAt != nil {
// Jangan ubah tabel kandang, hanya override status di response.
mapped.Status = string(utils.KandangStatusNonActive)
}
kandangSummaries[i] = KandangWithProjectFlockIdDTO{ kandangSummaries[i] = KandangWithProjectFlockIdDTO{
KandangRelationDTO: kandangDTO.ToKandangRelationDTO(kandang), KandangRelationDTO: mapped,
ProjectFlockKandangId: pfkId, ProjectFlockKandangId: pfkId,
Period: period, Period: period,
ClosedAt: closedAt,
} }
} }
} }
@@ -196,7 +196,11 @@ func (h *Controller) Refresh(c *fiber.Ctx) error {
verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) verification, err := sso.VerifyAccessToken(tokenResp.AccessToken)
if err != nil { if err != nil {
utils.Log.Errorf("access token verification failed: %v", err) if sso.IsSignatureError(err) {
logSignatureError("sso refresh", "sso_token", tokenResp.AccessToken, err)
} else {
utils.Log.Errorf("access token verification failed: %v", err)
}
return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") return fiber.NewError(fiber.StatusUnauthorized, "invalid access token")
} }
@@ -304,7 +308,11 @@ func (h *Controller) Callback(c *fiber.Ctx) error {
verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) verification, err := sso.VerifyAccessToken(tokenResp.AccessToken)
if err != nil { if err != nil {
utils.Log.Errorf("access token verification failed: %v", err) if sso.IsSignatureError(err) {
logSignatureError("sso callback", "sso_token", tokenResp.AccessToken, err)
} else {
utils.Log.Errorf("access token verification failed: %v", err)
}
return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") return fiber.NewError(fiber.StatusUnauthorized, "invalid access token")
} }
@@ -337,6 +345,22 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
token := strings.TrimSpace(c.Cookies(accessName)) token := strings.TrimSpace(c.Cookies(accessName))
tokenFromCookie := token != "" tokenFromCookie := token != ""
usedCookieName := accessName
if !tokenFromCookie {
for _, name := range config.SSOAccessCookieFallback {
name = strings.TrimSpace(name)
if name == "" || name == accessName {
continue
}
token = strings.TrimSpace(c.Cookies(name))
if token != "" {
tokenFromCookie = true
usedCookieName = name
break
}
}
}
if !tokenFromCookie { if !tokenFromCookie {
authHeader := strings.TrimSpace(c.Get("Authorization")) authHeader := strings.TrimSpace(c.Get("Authorization"))
@@ -363,7 +387,11 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
} }
if _, err := sso.VerifyAccessToken(token); err != nil { if _, err := sso.VerifyAccessToken(token); err != nil {
utils.Log.WithError(err).Warn("access token verification failed for userinfo") if sso.IsSignatureError(err) {
logSignatureError("sso userinfo", "request", token, err)
} else {
utils.Log.WithError(err).Warn("access token verification failed for userinfo")
}
return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated")
} }
@@ -382,7 +410,7 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
// SSO /auth/get-me expects the access cookie; add Authorization as well for compatibility. // SSO /auth/get-me expects the access cookie; add Authorization as well for compatibility.
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
if tokenFromCookie { if tokenFromCookie {
req.Header.Set("Cookie", fmt.Sprintf("%s=%s", accessName, token)) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", usedCookieName, token))
} }
resp, err := h.httpClient.Do(req) resp, err := h.httpClient.Do(req)
@@ -400,13 +428,6 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response") return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response")
} }
// if sanitized, perms, ok := sanitizeUserInfoPayload(body); ok {
// if caps := capabilities.FromPermissions(perms); len(caps) > 0 {
// injectCapabilities(sanitized, caps)
// }
// return c.Status(resp.StatusCode).JSON(sanitized)
// }
if ct := resp.Header.Get("Content-Type"); ct != "" { if ct := resp.Header.Get("Content-Type"); ct != "" {
c.Set("Content-Type", ct) c.Set("Content-Type", ct)
} else { } else {
@@ -418,17 +439,9 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
// Logout clears SSO cookies and removes any leftover PKCE session state. // Logout clears SSO cookies and removes any leftover PKCE session state.
func (h *Controller) Logout(c *fiber.Ctx) error { func (h *Controller) Logout(c *fiber.Ctx) error {
requestedAlias := normalizeClientParam(c.Query("client")) alias := ""
if requestedAlias == "" { if singleAlias, _, ok := singleSSOClient(); ok {
requestedAlias = normalizeClientParam(c.Query("client_id")) alias = singleAlias
}
var (
alias string
cfg config.SSOClientConfig
hasClientInfo bool
)
if requestedAlias != "" {
alias, cfg, hasClientInfo = findSSOClientConfig(requestedAlias)
} }
accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access") accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access")
@@ -445,14 +458,7 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
hadAccessCookie := accessToken != "" hadAccessCookie := accessToken != ""
hadRefreshCookie := refreshToken != "" hadRefreshCookie := refreshToken != ""
state := strings.TrimSpace(c.Query("state")) if !hadAccessCookie && !hadRefreshCookie {
if state != "" {
if err := h.store.Delete(c.Context(), state); err != nil {
utils.Log.Warnf("failed to delete pkce session during logout: %v", err)
}
}
if !hadAccessCookie && !hadRefreshCookie && state == "" {
return fiber.NewError(fiber.StatusUnauthorized, "not authenticated") return fiber.NewError(fiber.StatusUnauthorized, "not authenticated")
} }
@@ -477,52 +483,20 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
clearSSOCookie(c, refreshName) clearSSOCookie(c, refreshName)
redirectTarget := "" redirectTarget := ""
rawReturn := strings.TrimSpace(c.Query("return_to")) if config.SSOPortalURL != "" {
if hasClientInfo { redirectTarget = config.SSOPortalURL
if rawReturn == "" {
rawReturn = cfg.DefaultReturnURI
}
if normalized, err := normalizeReturnTarget(rawReturn, cfg); err == nil {
redirectTarget = normalized
} else if rawReturn != "" {
utils.Log.WithError(err).Warn("invalid return_to during logout")
}
} else if rawReturn == "" && config.SSOPortalURL != "" {
if alias, singleCfg, ok := singleClientFromToken(verification); ok {
if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" {
redirectTarget = normalized
alias, cfg, hasClientInfo = alias, singleCfg, true
} else {
redirectTarget = config.SSOPortalURL
}
} else if accessToken != "" {
if alias, singleCfg, ok := h.singleClientFromSSO(c.Context(), accessToken); ok {
if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" {
redirectTarget = normalized
alias, cfg, hasClientInfo = alias, singleCfg, true
} else {
redirectTarget = config.SSOPortalURL
}
} else {
redirectTarget = config.SSOPortalURL
}
} else {
redirectTarget = config.SSOPortalURL
}
} else if rawReturn != "" {
if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") {
redirectTarget = rawReturn
}
} }
utils.Log.WithFields(logrus.Fields{ utils.Log.WithFields(logrus.Fields{
"client": alias, "client": alias,
"state": state,
"redirect": redirectTarget, "redirect": redirectTarget,
}).Info("sso logout completed") }).Info("sso logout completed")
if redirectTarget != "" { if redirectTarget != "" {
return c.Redirect(redirectTarget, fiber.StatusFound) return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "signed out",
"redirect": redirectTarget,
})
} }
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"}) return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"})
@@ -541,145 +515,6 @@ func singleSSOClient() (string, config.SSOClientConfig, bool) {
return "", config.SSOClientConfig{}, false return "", config.SSOClientConfig{}, false
} }
func singleClientFromToken(verification *sso.VerificationResult) (string, config.SSOClientConfig, bool) {
if verification == nil || verification.Claims == nil {
return "", config.SSOClientConfig{}, false
}
return singleClientFromScopes(verification.Claims.Scopes())
}
func (h *Controller) singleClientFromSSO(ctx context.Context, accessToken string) (string, config.SSOClientConfig, bool) {
accessToken = strings.TrimSpace(accessToken)
if accessToken == "" {
return "", config.SSOClientConfig{}, false
}
meURL := strings.TrimSpace(config.SSOGetMeURL)
if meURL == "" {
return "", config.SSOClientConfig{}, false
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
if err != nil {
utils.Log.WithError(err).Warn("failed to build SSO getme request")
return "", config.SSOClientConfig{}, false
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := h.httpClient.Do(req)
if err != nil {
utils.Log.WithError(err).Warn("SSO getme request failed")
return "", config.SSOClientConfig{}, false
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
utils.Log.WithField("status", resp.StatusCode).Warn("SSO getme responded with error")
return "", config.SSOClientConfig{}, false
}
var payload struct {
Data struct {
Roles []struct {
Client *struct {
Alias string `json:"alias"`
} `json:"client"`
} `json:"roles"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
utils.Log.WithError(err).Warn("failed to decode SSO getme response")
return "", config.SSOClientConfig{}, false
}
aliases := make(map[string]struct{})
for _, role := range payload.Data.Roles {
if role.Client == nil {
continue
}
alias := strings.ToLower(strings.TrimSpace(role.Client.Alias))
if alias != "" {
aliases[alias] = struct{}{}
}
}
if len(aliases) != 1 {
return "", config.SSOClientConfig{}, false
}
for alias := range aliases {
if normalized, cfg, ok := findClientAlias(alias); ok {
return normalized, cfg, true
}
return "", config.SSOClientConfig{}, false
}
return "", config.SSOClientConfig{}, false
}
func singleClientFromScopes(scopes []string) (string, config.SSOClientConfig, bool) {
if len(scopes) == 0 {
return "", config.SSOClientConfig{}, false
}
seen := make(map[string]struct{})
for _, scope := range scopes {
if alias, ok := matchClientAliasFromScope(scope); ok {
seen[alias] = struct{}{}
}
if len(seen) > 1 {
return "", config.SSOClientConfig{}, false
}
}
if len(seen) != 1 {
return "", config.SSOClientConfig{}, false
}
for alias := range seen {
if normalized, cfg, ok := findClientAlias(alias); ok {
return normalized, cfg, true
}
}
return "", config.SSOClientConfig{}, false
}
func matchClientAliasFromScope(scope string) (string, bool) {
scope = strings.ToLower(strings.TrimSpace(scope))
if scope == "" {
return "", false
}
prefix := scope
if idx := strings.IndexAny(prefix, ".:"); idx > 0 {
prefix = prefix[:idx]
}
if prefix == "" {
return "", false
}
if alias, _, ok := findClientAlias(prefix); ok {
return alias, true
}
if prefix == "user-management" {
if alias, _, ok := findClientAlias("umgmt"); ok {
return alias, true
}
}
if prefix == "umgmt" {
if alias, _, ok := findClientAlias("user-management"); ok {
return alias, true
}
}
return "", false
}
func findClientAlias(alias string) (string, config.SSOClientConfig, bool) {
alias = strings.TrimSpace(alias)
if alias == "" {
return "", config.SSOClientConfig{}, false
}
if cfg, ok := config.SSOClients[alias]; ok && strings.TrimSpace(cfg.PublicID) != "" {
return alias, cfg, true
}
for key, cfg := range config.SSOClients {
if strings.EqualFold(key, alias) && strings.TrimSpace(cfg.PublicID) != "" {
return key, cfg, true
}
}
return "", config.SSOClientConfig{}, false
}
func defaultSSOClientAlias() string { func defaultSSOClientAlias() string {
for alias := range config.SSOClients { for alias := range config.SSOClients {
@@ -836,6 +671,27 @@ func resolveSSOCookieName(configuredName, fallback string) string {
return strings.TrimSpace(fallback) return strings.TrimSpace(fallback)
} }
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 jwks=%s",
err,
ctxLabel,
tokenSource,
info.Iss,
info.Kid,
aud,
info.Sub,
info.Exp,
info.Iat,
info.Nbf,
config.SSOIssuer,
config.SSOAllowedAudiences,
config.SSOJWKSURL,
)
}
func normalizeClientParam(raw string) string { func normalizeClientParam(raw string) string {
value := strings.TrimSpace(raw) value := strings.TrimSpace(raw)
if value == "" { if value == "" {
@@ -848,98 +704,6 @@ func normalizeClientParam(raw string) string {
return strings.ToLower(value) return strings.ToLower(value)
} }
func sanitizeUserInfoPayload(body []byte) (map[string]any, []string, bool) {
if len(body) == 0 {
return map[string]any{}, nil, true
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, nil, false
}
perms := collectPermissionNames(payload)
sensitive := map[string]struct{}{
"roles": {},
"permissions": {},
}
payload = scrubSensitiveKeys(payload, sensitive)
sanitized, ok := payload.(map[string]any)
if !ok {
sanitized = map[string]any{"data": payload}
}
return sanitized, perms, true
}
func scrubSensitiveKeys(value any, sensitive map[string]struct{}) any {
switch v := value.(type) {
case map[string]any:
for key, val := range v {
if _, ok := sensitive[strings.ToLower(key)]; ok {
delete(v, key)
continue
}
v[key] = scrubSensitiveKeys(val, sensitive)
}
return v
case []any:
for i, item := range v {
v[i] = scrubSensitiveKeys(item, sensitive)
}
return v
default:
return value
}
}
func collectPermissionNames(value any) []string {
names := make(map[string]struct{})
collectPermissionRec(value, names)
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
return out
}
func collectPermissionRec(value any, acc map[string]struct{}) {
switch v := value.(type) {
case map[string]any:
for key, val := range v {
if strings.EqualFold(key, "permissions") {
if arr, ok := val.([]any); ok {
for _, item := range arr {
if perm, ok := item.(map[string]any); ok {
if name, ok := perm["name"].(string); ok && strings.TrimSpace(name) != "" {
acc[strings.ToLower(strings.TrimSpace(name))] = struct{}{}
}
}
}
}
} else {
collectPermissionRec(val, acc)
}
}
case []any:
for _, item := range v {
collectPermissionRec(item, acc)
}
}
}
func injectCapabilities(payload map[string]any, caps map[string]bool) {
if len(caps) == 0 {
return
}
if data, ok := payload["data"].(map[string]any); ok {
data["capabilities"] = caps
return
}
payload["capabilities"] = caps
}
func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) { func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) {
if requestedAlias == "" { if requestedAlias == "" {
+126 -2
View File
@@ -2,9 +2,11 @@ package sso
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -41,6 +43,16 @@ type VerificationResult struct {
Claims *AccessTokenClaims Claims *AccessTokenClaims
} }
type TokenInfo struct {
Kid string
Iss string
Aud []string
Sub string
Exp int64
Iat int64
Nbf int64
}
var ( var (
globalMu sync.RWMutex globalMu sync.RWMutex
globalV *verifier globalV *verifier
@@ -106,10 +118,19 @@ func VerifyAccessToken(token string) (*VerificationResult, error) {
jwt.WithIssuedAt(), jwt.WithIssuedAt(),
jwt.WithExpirationRequired(), jwt.WithExpirationRequired(),
) )
tok, err := parser.ParseWithClaims(token, claims, v.jwks.Keyfunc) tok, err := parser.ParseWithClaims(token, claims, v.jwks.Keyfunc)
if err != nil { if err != nil {
return nil, fmt.Errorf("parse token: %w", err) if shouldRefreshOnVerifyError(err) {
if refreshErr := v.jwks.Refresh(context.Background(), keyfunc.RefreshOptions{IgnoreRateLimit: true}); refreshErr != nil {
utils.Log.WithError(refreshErr).Warn("sso jwks refresh after signature error failed")
} else {
tok, err = parser.ParseWithClaims(token, claims, v.jwks.Keyfunc)
}
}
if err != nil {
return nil, fmt.Errorf("parse token: %w", err)
}
} }
if !tok.Valid { if !tok.Valid {
return nil, errors.New("invalid token") return nil, errors.New("invalid token")
@@ -158,3 +179,106 @@ func VerifyAccessToken(token string) (*VerificationResult, error) {
return result, nil return result, nil
} }
func shouldRefreshOnVerifyError(err error) bool {
if !IsSignatureError(err) {
return false
}
return !disableRefreshOnSignatureError()
}
func IsSignatureError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "verification error") || strings.Contains(msg, "token signature is invalid")
}
func disableRefreshOnSignatureError() bool {
val := strings.TrimSpace(os.Getenv("SSO_DISABLE_JWKS_REFRESH_ON_SIG_ERROR"))
if val == "" {
return false
}
return val == "1" || strings.EqualFold(val, "true") || strings.EqualFold(val, "yes")
}
func ExtractTokenInfo(token string) TokenInfo {
token = strings.TrimSpace(token)
if token == "" {
return TokenInfo{}
}
claims := jwt.MapClaims{}
parser := jwt.NewParser()
tok, _, err := parser.ParseUnverified(token, claims)
if err != nil {
return TokenInfo{}
}
info := TokenInfo{}
if kid, ok := tok.Header["kid"].(string); ok {
info.Kid = kid
}
if iss, ok := claims["iss"].(string); ok {
info.Iss = iss
}
if sub, ok := claims["sub"].(string); ok {
info.Sub = sub
}
if aud, ok := claims["aud"]; ok {
info.Aud = toStringSlice(aud)
}
info.Exp = toInt64(claims["exp"])
info.Iat = toInt64(claims["iat"])
info.Nbf = toInt64(claims["nbf"])
return info
}
func toStringSlice(v any) []string {
switch t := v.(type) {
case string:
if t == "" {
return nil
}
return []string{t}
case []string:
out := make([]string, 0, len(t))
for _, s := range t {
if s != "" {
out = append(out, s)
}
}
return out
case []any:
out := make([]string, 0, len(t))
for _, item := range t {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func toInt64(v any) int64 {
switch t := v.(type) {
case int64:
return t
case int:
return int64(t)
case float64:
return int64(t)
case json.Number:
if n, err := t.Int64(); err == nil {
return n
}
case string:
if n, err := strconv.ParseInt(t, 10, 64); err == nil {
return n
}
}
return 0
}
+38
View File
@@ -0,0 +1,38 @@
# DB Jobs for dev-lti
These manifests are intended for EKS + Argo CD deployment flow.
## Files
- `migrate-dev-lti-presync.yaml`: Argo CD `PreSync` migration Job.
- `seed-dev-lti-manual.yaml`: manual seed Job.
- `fresh-dev-lti-manual.yaml`: manual reset + migrate Job (guarded with `ALLOW_DB_RESET`).
## Important
- Update the image tag in all manifests to the same tag deployed by Argo for each release.
- Jobs currently read environment variables from secret `lti-env-dev`.
- Required DB env vars: `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, optional `DB_SSLMODE`.
## Manual run
Seed:
```bash
kubectl delete job -n dev-lti lti-db-seed-manual --ignore-not-found
kubectl apply -f k8s/jobs/seed-dev-lti-manual.yaml
kubectl logs -n dev-lti job/lti-db-seed-manual -f
```
Fresh/reset (dev only):
```bash
kubectl delete job -n dev-lti lti-db-fresh-manual --ignore-not-found
sed 's/value: "NO"/value: "YES"/' k8s/jobs/fresh-dev-lti-manual.yaml | kubectl apply -f -
kubectl logs -n dev-lti job/lti-db-fresh-manual -f
```
## Argo CD note
For `migrate-dev-lti-presync.yaml`, keep annotation:
- `argocd.argoproj.io/hook: PreSync`
- `argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded`
so migration runs before workload sync.
+38
View File
@@ -0,0 +1,38 @@
apiVersion: batch/v1
kind: Job
metadata:
name: lti-db-fresh-manual
namespace: dev-lti
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 300
template:
spec:
restartPolicy: Never
containers:
- name: fresh
image: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com/mbugroup/lti-api:dev-f8ca404b
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- |
set -eu
if [ "${ALLOW_DB_RESET:-}" != "YES" ]; then
echo "Refusing to reset DB. Set ALLOW_DB_RESET=YES to continue."
exit 1
fi
DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "Resetting schema public on ${DB_HOST}:${DB_PORT}/${DB_NAME}"
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "DROP SCHEMA IF EXISTS public CASCADE;"
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "CREATE SCHEMA public;"
echo "Running migrations after reset"
/app/migrate -path /app/migrations -database "$DATABASE_URL" up
env:
- name: ALLOW_DB_RESET
value: "NO"
envFrom:
- secretRef:
name: lti-env-dev
+28
View File
@@ -0,0 +1,28 @@
apiVersion: batch/v1
kind: Job
metadata:
name: lti-db-migrate
namespace: dev-lti
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 300
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com/mbugroup/lti-api:dev-f8ca404b
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- |
set -eu
DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "Running migrations on ${DB_HOST}:${DB_PORT}/${DB_NAME}"
/app/migrate -path /app/migrations -database "$DATABASE_URL" up
envFrom:
- secretRef:
name: lti-env-dev
+19
View File
@@ -0,0 +1,19 @@
apiVersion: batch/v1
kind: Job
metadata:
name: lti-db-seed-manual
namespace: dev-lti
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 300
template:
spec:
restartPolicy: Never
containers:
- name: seed
image: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com/mbugroup/lti-api:dev-f8ca404b
imagePullPolicy: IfNotPresent
command: ["/app/lti-seed"]
envFrom:
- secretRef:
name: lti-env-dev