Compare commits

...

42 Commits

Author SHA1 Message Date
M1 AIR febc228115 ci: align mr development production triggers 2026-03-05 00:22:34 +07:00
M1 AIR 54e4878406 ci: use self-hosted-dev runner tags 2026-03-05 00:08:50 +07:00
M1 AIR 77ac46a029 ci: adjust lti gitlab pipeline for ecr gitops 2026-03-05 00:04:23 +07:00
M1 AIR 8a006f377e Update flow environment 2026-03-04 23:45:06 +07:00
M1 AIR 18db58a87b test: MR pipeline notification 2026-02-07 00:14:58 +07:00
M1 AIR 1a56b37e4e Create job for MR 2026-02-06 23:36:20 +07:00
Hafizh A. Y. 3011735458 Merge branch 'fix/hpp-harian-feed' into 'development'
[FIX][BE]: add query for feed supplier

See merge request mbugroup/lti-api!312
2026-02-06 04:29:35 +00:00
Hafizh A. Y. d40aac8960 Merge branch 'fix/search-finance' into 'development'
[FIX][BE] Add payment method search in module finance

See merge request mbugroup/lti-api!313
2026-02-06 04:29:03 +00:00
Hafizh A. Y 18672f541e fix(BE): add payment method search in module finance 2026-02-06 11:27:48 +07:00
giovanni 58aed76bbb add query for feed supplier 2026-02-06 10:58:54 +07:00
Hafizh A. Y. 505db703d8 Merge branch 'fix/filter-report-customer' into 'development'
[FIX][BE] Filter by transaction or realization in report customer payment

See merge request mbugroup/lti-api!311
2026-02-05 07:19:07 +00:00
Hafizh A. Y. cc19b626e1 Merge branch 'fix/sapronak' into 'development'
[FIX][BE]: adjust query value notes

See merge request mbugroup/lti-api!309
2026-02-05 07:17:06 +00:00
Hafizh A. Y fc157dfd79 fix(BE): filter by transaction or realization in report customer payment 2026-02-05 14:16:02 +07:00
Hafizh A. Y. f407ef6a0c Merge branch 'fix/multiple-filter-finance' into 'development'
fix(BE): multiple filter, all search

See merge request mbugroup/lti-api!308
2026-02-05 03:28:26 +00:00
giovanni 248ca1d522 adjust query value notes 2026-02-05 10:27:00 +07:00
Hafizh A. Y d41f1b9495 fix(BE): multiple filter, all search 2026-02-05 10:26:44 +07:00
Hafizh A. Y. 6efab80686 Merge branch 'FEAT/BE/marketing' into 'development'
FEAT[BE] : update new concept of marketing

See merge request mbugroup/lti-api!307
2026-02-05 03:23:00 +00:00
Hafizh A. Y. 6253ca46bc Merge branch 'fix/sapronak' into 'development'
[FIX][BE]: fix query field source_warehouse type adjustment stock sapronak masuk

See merge request mbugroup/lti-api!306
2026-02-05 03:06:48 +00:00
giovanni 1f10e96288 fix query field source_warehouse 2026-02-04 15:17:05 +07:00
Hafizh A. Y. ae69b138bf Merge branch 'fix/hasil-produksi' into 'development'
[FIX][BE]: fix hasil produksi deplesi std dan filter recording approved

See merge request mbugroup/lti-api!305
2026-02-04 07:50:54 +00:00
Hafizh A. Y. b2dd9a6e13 Merge branch 'feat/sapronak' into 'development'
[FEAT][BE]: add query adjustment stock at closing sapronak

See merge request mbugroup/lti-api!304
2026-02-04 07:50:07 +00:00
Hafizh A. Y. 9bc66798d4 Merge branch 'fix/BE/remove_fcr_id_project-flock' into 'development'
[FEAT/BE]Fix remove fcr master data and changes to standart production

See merge request mbugroup/lti-api!303
2026-02-04 07:49:44 +00:00
giovanni 114f1a7c24 fix hasil produksi deplesi std dan filter recording approved 2026-02-04 13:51:55 +07:00
ragilap 14cc7ef2ae [FEAT/BE] Add field purchase response get all 2026-02-04 13:31:20 +07:00
giovanni 7183df6938 add query adjustment stock at closing sapronak 2026-02-04 09:17:16 +07:00
ragilap b862fc4113 [FEAT/BE]Fix remove fcr master data and changes to standart production 2026-02-03 17:01:50 +07:00
Hafizh A. Y. 22038533d7 Merge branch 'FEAT/BE/Production_standard' into 'development'
FEAT[BE] :add production standard detail creation for growing only FCR

See merge request mbugroup/lti-api!302
2026-02-03 05:58:15 +00:00
Hafizh A. Y. 5641cadeec Merge branch 'Fix/BE/recording_avaible_qty' into 'development'
[FEAT/BE]Fix add new response depletions_rate

See merge request mbugroup/lti-api!301
2026-02-03 05:09:53 +00:00
ragilap 4eacdd543a [FEAT/BE]Fix add new response depletions_rate 2026-02-03 12:08:11 +07:00
Hafizh A. Y. a0221bb79c Merge branch 'Fix/BE/recording_avaible_qty' into 'development'
[FEAT/BE]Fix create avaible qty

See merge request mbugroup/lti-api!300
2026-02-03 04:18:06 +00:00
ragilap 82f0db107a [FEAT/BE]Fix create avaible qty 2026-02-03 11:17:11 +07:00
Hafizh A. Y. d2ce0918b5 Merge branch 'dev/teguh' into 'development'
FIX/BE: fix closing keuangan kalkulasi penjualan, marketing fifo, transfer laying fifo stock alocation

See merge request mbugroup/lti-api!298
2026-02-03 02:34:33 +00:00
Hafizh A. Y. 8f4fce1219 Merge branch 'Fix/BE/Purchase-rejected' into 'development'
Fix/be/purchase rejected

See merge request mbugroup/lti-api!297
2026-02-03 02:34:09 +00:00
Hafizh A. Y. b7ecf1dfcf Merge branch 'fix/BE/US-281-adjustment-recording-egg-mass' into 'development'
[FEAT/BE] fix bug recording and closing counting sapronak

See merge request mbugroup/lti-api!296
2026-02-03 02:33:54 +00:00
ragilap e968b8ed9c Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into Fix/BE/Purchase-rejected 2026-02-02 15:24:11 +07:00
ragilap 96627e964f [FEAT/BE]Purchase rejected payload 2026-02-02 15:23:15 +07:00
ragilap 6e0ff557a8 [FEAT/BE]delete adjustment use in doc closing counting sapronak 2026-02-02 14:59:29 +07:00
ragilap f4790e56ea [FEAT/BE] update herautics for closing counting sapronak 2026-02-02 14:25:37 +07:00
ragilap 760b37449e [FEAT/BE] fix bug recording and closing counting sapronak 2026-02-02 14:08:29 +07:00
Hafizh A. Y. fb2a7a6676 Merge branch 'fix/inventory-stock' into 'development'
[FIX][BE]: add response stock to informasi stock product

See merge request mbugroup/lti-api!295
2026-02-02 06:18:10 +00:00
Hafizh A. Y. a89c2edb99 Merge branch 'dev/teguh' into 'development'
FEAT[BE] : change transfer to laying number to correct format

See merge request mbugroup/lti-api!294
2026-02-02 06:17:48 +00:00
giovanni 9bf33d2bae add response stock to informasi stock product 2026-02-02 12:15:41 +07:00
47 changed files with 2055 additions and 1169 deletions
+136 -15
View File
@@ -1,21 +1,142 @@
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}
DOCKER_HOST: unix:///var/run/docker.sock
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "development"'
- if: '$CI_COMMIT_BRANCH == "staging"'
- if: '$CI_COMMIT_BRANCH == "production"'
# run untuk branch utama & MR
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- when: never
include:
- local: "ci/development.yml"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "development"'
# =========================
# 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
- local: "ci/staging.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
ecr get-login-password --region "$AWS_REGION" || true)"
if [ -z "$PASS" ]; then
echo "ERROR: Failed to get ECR login password."
exit 1
fi
echo "$PASS" | docker login --username AWS --password-stdin "$ECR_REGISTRY"
- local: "ci/production.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
# =========================
# MR
# =========================
build_mr:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
variables:
IMAGE_TAG: "prod-mr-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
echo "Build (MR) : $ECR_REPOSITORY:$IMAGE_TAG"
docker build -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
echo "Pushing image for MR..."
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
# =========================
# DEVELOPMENT (push branch development)
# =========================
build_push_dev:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
echo "Build & push (dev): $ECR_REPOSITORY:$IMAGE_TAG"
docker build -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
# =========================
# 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
echo "Build & push (prod): $ECR_REPOSITORY:$IMAGE_TAG"
docker build -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"
+1
View File
@@ -111,3 +111,4 @@ IT Development PT Mitra Berlian Unggas Group
## 📃 License
> This project is private. All rights reserved.
# mr test Sat 7 Feb 2026 00:14:58 WIB
+6 -5
View File
@@ -4,9 +4,14 @@ stages:
deploy-dev:
stage: deploy
image: alpine:3.20
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
when: on_success
- when: never
variables:
DEPLOY_APP: "LTI-MBUGROUP"
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
@@ -27,7 +32,6 @@ deploy-dev:
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
@@ -83,8 +87,5 @@ deploy-dev:
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
only:
- development
environment:
name: development
+48
View File
@@ -0,0 +1,48 @@
stages:
- notify
notify_discord_on_mr_request_main_dev:
stage: notify
image: alpine:3.20
rules:
# hanya MR yang target ke main atau development
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")'
when: on_success
- when: never
script:
- apk add --no-cache curl jq coreutils
- |
TIME_HUMAN="$(date '+%d/%m/%y, %H.%M')"
TIME_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
TITLE="${CI_MERGE_REQUEST_TITLE}"
IID="!${CI_MERGE_REQUEST_IID}"
USER_LINE="${GITLAB_USER_NAME} (${GITLAB_USER_LOGIN})"
PROJECT_PATH="${CI_PROJECT_PATH}"
USERNAME="${GITLAB_USER_LOGIN}"
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
DESC="$(printf "**%s**\n\n%s opened merge request %s %s\n%s" \
"$USERNAME" "$USER_LINE" "$IID" "$TITLE" "$TIME_HUMAN")"
payload=$(jq -n \
--arg desc "$DESC" \
--arg project "$PROJECT_PATH" \
--arg timeiso "$TIME_ISO" \
--arg mrurl "$MR_URL" \
'{
"username": "Mock-api - Merge Requests",
"embeds": [
{
"description": ($desc + "\n" + $mrurl),
"color": 15105570,
"footer": { "text": $project },
"timestamp": $timeiso
}
]
}')
curl -sS -H "Content-Type: application/json" \
-d "$payload" \
"$DISCORD_WEBHOOK_URL"
+12 -27
View File
@@ -8,12 +8,6 @@ default:
tags:
- self-hosted-prod
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
when: always
- when: never
variables:
DOCKER_BUILDKIT: "1"
@@ -30,7 +24,9 @@ variables:
build_production:
stage: build
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
script: |
set -e
docker info
@@ -47,14 +43,15 @@ build_production:
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (PRODUCTION)
# =========================
migrate_production:
stage: migrate
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
- job: build_production
artifacts: false
@@ -66,12 +63,10 @@ migrate_production:
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
# ✅ load env dari server
set -a
. ./.env
set +a
# ✅ validasi
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
@@ -81,21 +76,13 @@ migrate_production:
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
# ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!)
echo "✅ Ensuring postgres & redis running ..."
# NOTE: pastikan nama servicenya benar untuk production (ini sebelumnya masih stg-*)
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
# ✅ Ambil network key dari compose
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
# ✅ Cari network name yang dipakai docker
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Docker network detected: $NETWORK_NAME"
# ✅ Migrations dari repo (CI workspace)
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
@@ -111,7 +98,6 @@ migrate_production:
echo "$out"
# ✅ Handle no change dengan benar (tidak false-success)
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
@@ -124,17 +110,16 @@ migrate_production:
echo "✅ Migration applied successfully"
# =========================
# DEPLOY (AUTO)
# =========================
deploy_production:
stage: deploy
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
# - job: migrate_production
# artifacts: false
- job: build_production
artifacts: false
script: |
@@ -150,7 +135,6 @@ deploy_production:
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
@@ -159,9 +143,10 @@ seed_production:
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: manual
- when: never
script: |
set -e
cd /opt/deploy/lti
cd "$DEPLOY_DIR"
test -f .env || (echo "❌ .env not found" && exit 1)
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
+13 -22
View File
@@ -8,12 +8,6 @@ default:
tags:
- self-hosted-stg
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
when: always
- when: never
variables:
DOCKER_BUILDKIT: "1"
@@ -30,7 +24,9 @@ variables:
build_staging:
stage: build
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
script: |
set -e
docker info
@@ -47,14 +43,15 @@ build_staging:
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (AUTO)
# =========================
migrate_staging:
stage: migrate
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
needs:
- job: build_staging
artifacts: false
@@ -66,12 +63,10 @@ migrate_staging:
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
# ✅ load env dari server
set -a
. ./.env
set +a
# ✅ validasi
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
@@ -81,21 +76,17 @@ migrate_staging:
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
# ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!)
echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
# ✅ Ambil network key dari compose
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
# ✅ Cari network name yang dipakai docker
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Docker network detected: $NETWORK_NAME"
# ✅ Migrations dari repo (CI workspace)
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
@@ -111,7 +102,6 @@ migrate_staging:
echo "$out"
# ✅ Handle no change dengan benar (tidak false-success)
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
@@ -124,14 +114,15 @@ migrate_staging:
echo "✅ Migration applied successfully"
# =========================
# DEPLOY (AUTO)
# =========================
deploy_staging:
stage: deploy
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
needs:
- job: migrate_staging
artifacts: false
@@ -150,18 +141,18 @@ deploy_staging:
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_staging:
stage: seed
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
- if: '$CI_COMMIT_BRANCH == "staging"'
when: manual
- when: never
needs:
- job: deploy_staging
artifacts: false
when: manual
allow_failure: false
script: |
set -e
@@ -170,4 +161,4 @@ seed_staging:
test -f .env || (echo "❌ .env not found" && exit 1)
docker compose -f "$COMPOSE_FILE" pull seed || true
docker compose -f "$COMPOSE_FILE" run --rm seed%
docker compose -f "$COMPOSE_FILE" run --rm seed
+89 -36
View File
@@ -147,6 +147,7 @@ type StockReleaseRequest struct {
Reason *string
Tx *gorm.DB
}
func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return errors.New("stockable key and id are required")
@@ -308,7 +309,7 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
}
if reductionTarget > 0 {
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget)
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget, productWarehouseID)
if err != nil {
return err
}
@@ -355,7 +356,7 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
}
var usageDelta, pendingDelta float64
if ctxRow.UsageQty > 0 {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty, ctxRow.ProductWarehouseID); err != nil {
return err
}
usageDelta -= ctxRow.UsageQty
@@ -721,6 +722,7 @@ func (s *fifoService) releaseUsagePortion(
usableKey fifo.UsableKey,
usableID uint,
target float64,
expectedWarehouseID uint,
) (float64, error) {
if target <= 0 {
return 0, nil
@@ -736,6 +738,18 @@ func (s *fifoService) releaseUsagePortion(
if len(allocations) == 0 {
return 0, nil
}
for i := range allocations {
alloc := &allocations[i]
if expectedWarehouseID == 0 || alloc.ProductWarehouseId == expectedWarehouseID {
continue
}
if err := tx.Model(&entities.StockAllocation{}).
Where("id = ?", alloc.Id).
Update("product_warehouse_id", expectedWarehouseID).Error; err != nil {
return 0, err
}
alloc.ProductWarehouseId = expectedWarehouseID
}
var (
remaining = target
@@ -832,41 +846,80 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
cfg.Columns.CreatedAt,
)
var rows []struct {
ID uint
Pending float64
CreatedAt time.Time
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
if cfg.Columns.CreatedAt == cfg.Columns.ID {
var rows []struct {
ID uint
Pending float64
CreatedAt int64
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: time.Unix(0, row.CreatedAt),
})
}
} else {
var rows []struct {
ID uint
Pending float64
CreatedAt time.Time
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: row.CreatedAt,
})
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: row.CreatedAt,
})
}
}
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE adjustment_stocks
DROP COLUMN adj_number;
COMMIT;
@@ -0,0 +1,10 @@
BEGIN;
ALTER TABLE adjustment_stocks
ADD COLUMN adj_number VARCHAR(255);
UPDATE adjustment_stocks
SET adj_number = CONCAT('ADJ-', LPAD(id::text, 5, '0'))
WHERE adj_number IS NULL;
COMMIT;
@@ -0,0 +1,47 @@
BEGIN;
DO $$
DECLARE
fallback_fcr_id BIGINT;
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flocks'
AND column_name = 'fcr_id'
) THEN
ALTER TABLE project_flocks
ADD COLUMN fcr_id BIGINT;
END IF;
SELECT id INTO fallback_fcr_id
FROM fcrs
ORDER BY id ASC
LIMIT 1;
IF fallback_fcr_id IS NOT NULL THEN
UPDATE project_flocks
SET fcr_id = fallback_fcr_id
WHERE fcr_id IS NULL;
ALTER TABLE project_flocks
ALTER COLUMN fcr_id SET NOT NULL;
END IF;
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'project_flocks_fcr_id_fkey'
) THEN
ALTER TABLE project_flocks
DROP CONSTRAINT project_flocks_fcr_id_fkey;
END IF;
ALTER TABLE project_flocks
ADD CONSTRAINT project_flocks_fcr_id_fkey
FOREIGN KEY (fcr_id) REFERENCES fcrs(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END $$;
COMMIT;
@@ -0,0 +1,26 @@
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flocks'
AND column_name = 'fcr_id'
) THEN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'project_flocks_fcr_id_fkey'
) THEN
ALTER TABLE project_flocks
DROP CONSTRAINT project_flocks_fcr_id_fkey;
END IF;
ALTER TABLE project_flocks
DROP COLUMN fcr_id;
END IF;
END $$;
COMMIT;
+1
View File
@@ -11,6 +11,7 @@ type AdjustmentStock struct {
PendingQty float64 `gorm:"column:pending_qty;default:0"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
-2
View File
@@ -11,7 +11,6 @@ type ProjectFlock struct {
FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"`
AreaId uint `gorm:"not null"`
Category string `gorm:"type:varchar(20);not null"`
FcrId uint `gorm:"not null"`
ProductionStandardId uint `gorm:"column:production_standard_id"`
LocationId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"`
@@ -20,7 +19,6 @@ type ProjectFlock struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Area Area `gorm:"foreignKey:AreaId;references:Id"`
Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"`
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
+2
View File
@@ -12,7 +12,9 @@ type Recording struct {
RecordDatetime time.Time `gorm:"column:record_datetime;not null"`
Day *int `gorm:"column:day"`
TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"`
TotalDepletionCumQty *float64 `gorm:"-"`
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
DepletionRate *float64 `gorm:"-"`
CumIntake *int `gorm:"column:cum_intake"`
FcrValue *float64 `gorm:"column:fcr_value"`
TotalChickQty *float64 `gorm:"column:total_chick_qty"`
@@ -234,14 +234,14 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
row.Notes = "TRANSFER STOCK"
}
}
case "pemakaian", "adjustment keluar":
case "pemakaian":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price
case "mutasi keluar", "penjualan":
case "adjustment keluar", "mutasi keluar", "penjualan":
price := row.UnitPrice
if price == 0 {
price = item.Harga
@@ -24,7 +24,6 @@ type ClosingRepository interface {
SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (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)
GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error)
GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error)
FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error)
FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error)
@@ -36,6 +35,7 @@ type ClosingRepository interface {
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
}
@@ -101,12 +101,12 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
if len(params.WarehouseIDs) == 0 {
return []SapronakRow{}, 0, nil
}
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL)
args = append(args, params.WarehouseIDs)
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
}
if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
@@ -173,12 +173,12 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S
if len(params.WarehouseIDs) == 0 {
return []SapronakSummaryRow{}, nil
}
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL)
args = append(args, params.WarehouseIDs)
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
}
if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
@@ -392,22 +392,6 @@ func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFla
return agg.TotalQty, nil
}
func (r *ClosingRepositoryImpl) GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error) {
if fcrID == 0 {
return []entity.FcrStandard{}, nil
}
var standards []entity.FcrStandard
if err := r.DB().WithContext(ctx).
Where("fcr_id = ?", fcrID).
Order("weight ASC").
Find(&standards).Error; err != nil {
return nil, err
}
return standards, nil
}
func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) {
db := r.DB().WithContext(ctx)
@@ -455,7 +439,7 @@ SELECT
COALESCE(pi.received_date, '1970-01-01') AS sort_date,
COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(p.po_number, '') AS reference_number,
'Purchase' AS transaction_type,
'Pembelian' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
@@ -504,7 +488,7 @@ SELECT
st.transfer_date AS sort_date,
TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text,
st.movement_number AS reference_number,
'Internal Transfer In' AS transaction_type,
'Mutasi' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
@@ -538,7 +522,7 @@ SELECT
std.usage_qty AS quantity,
u.id AS unit_id,
u.name AS unit,
'Stock Refill' AS notes
st.reason AS notes
FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
@@ -548,13 +532,63 @@ JOIN uoms u ON u.id = prod.uom_id
WHERE st.to_warehouse_id IN ?
`
sapronakIncomingAdjustmentsSQL = `
SELECT
CAST(ast.id AS BIGINT) AS id,
ast.created_at AS sort_date,
COALESCE(TO_CHAR(ast.created_at, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(ast.adj_number, '') AS reference_number,
'Adjustment stock' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
'-' AS source_warehouse,
COALESCE(w.name, '') AS destination_warehouse,
'' AS destination,
COALESCE(ast.total_qty, 0) AS quantity,
u.id AS unit_id,
u.name AS unit,
'-' AS notes
FROM adjustment_stocks ast
JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id
WHERE pw.warehouse_id IN ?
AND COALESCE(ast.total_qty, 0) <> 0
`
sapronakOutgoingTransfersSQL = `
SELECT
CAST(st.id AS BIGINT) AS id,
st.transfer_date AS sort_date,
TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text,
st.movement_number AS reference_number,
'Internal Transfer Out' AS transaction_type,
'Mutasi' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
@@ -588,7 +622,7 @@ SELECT
std.usage_qty AS quantity,
u.id AS unit_id,
u.name AS unit,
'Transfer to other unit' AS notes
st.reason AS notes
FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
@@ -598,13 +632,70 @@ JOIN uoms u ON u.id = prod.uom_id
WHERE st.from_warehouse_id IN ?
`
sapronakOutgoingAdjustmentsSQL = `
SELECT
CAST(ast.id AS BIGINT) AS id,
ast.created_at AS sort_date,
COALESCE(TO_CHAR(ast.created_at, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(ast.adj_number, '') AS reference_number,
'Adjustment stock' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
COALESCE(w.name, '') AS source_warehouse,
'-' AS destination_warehouse,
'' AS destination,
COALESCE(ast.usage_qty, 0) AS quantity,
u.id AS unit_id,
u.name AS unit,
'-' AS notes
FROM adjustment_stocks ast
JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id
WHERE pw.warehouse_id IN ?
AND COALESCE(ast.usage_qty, 0) <> 0
AND EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_id = pw.product_id
AND f.flagable_type = 'products'
AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK')
)
`
sapronakOutgoingMarketingsSQL = `
SELECT
CAST(mp.id AS BIGINT) AS id,
m.so_date AS sort_date,
TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text,
m.so_number AS reference_number,
'Trading Sales' AS transaction_type,
'Penjualan' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
@@ -652,7 +743,7 @@ WHERE pw.project_flock_kandang_id IN ?
FROM flags f
WHERE f.flagable_id = pw.product_id
AND f.flagable_type = 'products'
AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET')
AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK')
)
`
)
@@ -939,6 +1030,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
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").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll).
Where(`
(sa.usable_type = ? AND r.project_flock_kandangs_id = ?)
@@ -1085,12 +1177,75 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
}
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false)
poByWarehouse := r.DB().
Table("purchase_items pi").
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date").
Joins("JOIN purchases po ON po.id = pi.purchase_id").
Where("pi.received_date IS NOT NULL").
Order("pi.product_warehouse_id, pi.received_date ASC")
incomingQuery := r.withCtx(ctx).
Table("adjustment_stocks AS ast").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
ast.created_at AS date,
CONCAT('ADJ-', ast.id) AS reference,
COALESCE(ast.total_qty, 0) AS qty_in,
0 AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("COALESCE(ast.total_qty, 0) > 0")
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil {
return nil, nil, err
}
in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { return fmt.Sprintf("ADJ-%d", row.ID) })
return in, out, nil
outgoingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
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) AS date,
COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.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 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 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 adjustment_stocks ast_in ON ast_in.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 (?) pfp_po ON pfp_po.product_warehouse_id = pfp.product_warehouse_id", poByWarehouse).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
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")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil {
return nil, nil, err
}
return incoming, outgoing, nil
}
func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
@@ -1286,6 +1441,59 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
return sales, nil
}
func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil
}
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
ast.created_at
) AS date,
COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
CONCAT('ADJ-', ast.id),
''
) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.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 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 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 adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group(`
pw.product_id, p.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at,
po.po_number, st.movement_number, lt.transfer_number, ast.id,
pi.price, p.product_price
`)
query = r.joinSapronakProductFlag(query, "p")
return scanAndGroupDetails(query)
}
func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) {
if len(productIDs) == 0 {
return []entity.Product{}, nil
@@ -836,14 +836,6 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
finalPopulation := population - claimCulling
var standards []entity.FcrStandard
if project.FcrId > 0 {
standards, err = s.Repository.GetFcrStandardsByFcrID(c.Context(), project.FcrId)
if err != nil {
s.Log.Errorf("Failed to fetch FCR standards for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data")
}
}
age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID, kandangID)
if err != nil {
s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err)
@@ -893,7 +885,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
chickenDepletion = 0
}
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age, standards)
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age)
if fcrActFromRecording != nil {
chickenPerformance.FcrAct = *fcrActFromRecording
}
@@ -943,7 +935,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
eggDepletion = 0
}
eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age, standards)
eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age)
if fcrActFromRecording != nil {
eggPerf.FcrAct = *fcrActFromRecording
}
@@ -1001,10 +993,10 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
performance.EggMass = eggMass
}
}
performance.DeffFcr = performance.FcrStd - performance.FcrAct
if productionStandardDetail != nil {
if productionStandardDetail.StandardFCR != nil {
performance.FcrStd = *productionStandardDetail.StandardFCR
performance.DeffFcr = performance.FcrStd - performance.FcrAct
}
if !isGrowing {
if productionStandardDetail.TargetHenDayProduction != nil {
@@ -1091,8 +1083,8 @@ func (s closingService) determineProductionWeek(ctx context.Context, projectFloc
return week, nil
}
func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO {
mortalityStd, fcrStd := closestFcrValues(standards, averageWeight)
func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64) dto.ClosingPerformanceDTO {
mortalityStd, fcrStd := 0.0, 0.0
fcrAct := 0.0
if totalWeight > 0 {
@@ -1124,21 +1116,3 @@ func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopul
AwgAct: awg,
}
}
func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (float64, float64) {
if len(standards) == 0 || averageWeight <= 0 {
return 0, 0
}
closest := standards[0]
minDiff := math.Abs(closest.Weight - averageWeight)
for _, std := range standards[1:] {
diff := math.Abs(std.Weight - averageWeight)
if diff < minDiff {
minDiff = diff
closest = std
}
}
return closest.Mortality, closest.FcrNumber
}
@@ -363,7 +363,7 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if err != nil {
return nil, nil, 0, 0, err
}
salesOutRows, err := s.Repository.FetchSapronakSales(ctx, pfk.Id)
salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
@@ -570,13 +570,12 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if existing.ProductName == "" {
existing.ProductName = d.ProductName
}
existing.UsageQty += d.QtyKeluar
existing.UsageValue += d.Nilai
if existing.IncomingQty >= existing.UsageQty {
existing.RemainingQty = existing.IncomingQty - existing.UsageQty
} else {
existing.RemainingQty = 0
// Adjustment keluar should reduce stock without inflating usage-based HPP.
remaining := existing.IncomingQty - existing.UsageQty - d.QtyKeluar
if remaining < 0 {
remaining = 0
}
existing.RemainingQty = remaining
itemMap[productID] = existing
}
}
@@ -107,16 +107,23 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go
func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) {
var rows []RecordingWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx).
Table("recordings AS r").
Select(`((r.day - 1) / 7 + 1) AS week,
Select(fmt.Sprintf(`%s AS week,
COALESCE(AVG(r.hen_day), 0) AS hen_day,
COALESCE(AVG(r.egg_weight), 0) AS egg_weight,
COALESCE(AVG(r.feed_intake), 0) AS feed_intake,
COALESCE(AVG(r.fcr_value), 0) AS fcr_value,
COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`).
COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`, weekExpr)).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL").
Where("r.day IS NOT NULL AND r.day > 0")
@@ -188,92 +195,19 @@ func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, week
return nil, nil
}
filterClause := ""
filterArgs := make([]interface{}, 0)
if filters != nil {
if len(filters.FlockIds) > 0 {
filterClause += " AND pf.id IN ?"
filterArgs = append(filterArgs, filters.FlockIds)
}
if len(filters.KandangIds) > 0 {
filterClause += " AND k.id IN ?"
filterArgs = append(filterArgs, filters.KandangIds)
}
if len(filters.LokasiIds) > 0 {
filterClause += " AND k.location_id IN ?"
filterArgs = append(filterArgs, filters.LokasiIds)
}
standardIDs := r.standardIDSubquery(filters)
if standardIDs == nil {
return nil, nil
}
query := fmt.Sprintf(`
WITH src AS (
SELECT DISTINCT pf.production_standard_id, pf.fcr_id
FROM project_flocks pf
JOIN project_flock_kandangs pfk ON pfk.project_flock_id = pf.id
JOIN kandangs k ON k.id = pfk.kandang_id
WHERE pf.production_standard_id > 0 AND pf.fcr_id > 0
%s
),
actual AS (
SELECT u.week AS week,
pf.fcr_id AS fcr_id,
AVG((u.chart_data->'statistics'->>'average_weight')::numeric) AS avg_weight
FROM project_flock_kandang_uniformity u
JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
JOIN kandangs k ON k.id = pfk.kandang_id
WHERE u.week IN ? AND u.uniform_date IS NOT NULL AND pf.fcr_id > 0
%s
GROUP BY u.week, pf.fcr_id
),
target AS (
SELECT sgd.week AS week,
src.fcr_id AS fcr_id,
AVG(sgd.target_mean_bw) AS target_mean_bw
FROM standard_growth_details sgd
JOIN src ON src.production_standard_id = sgd.production_standard_id
WHERE sgd.week IN ?
GROUP BY sgd.week, src.fcr_id
),
weights AS (
SELECT COALESCE(a.week, t.week) AS week,
COALESCE(a.fcr_id, t.fcr_id) AS fcr_id,
COALESCE(
CASE WHEN a.avg_weight > 10 THEN a.avg_weight / 1000 ELSE a.avg_weight END,
CASE WHEN t.target_mean_bw > 10 THEN t.target_mean_bw / 1000 ELSE t.target_mean_bw END
) AS weight
FROM actual a
FULL OUTER JOIN target t ON t.week = a.week AND t.fcr_id = a.fcr_id
)
SELECT w.week AS week,
COALESCE(AVG(
COALESCE(
(SELECT fs.fcr_number
FROM fcr_standards fs
WHERE fs.fcr_id = w.fcr_id
AND fs.weight >= w.weight
ORDER BY fs.weight ASC
LIMIT 1),
(SELECT fs.fcr_number
FROM fcr_standards fs
WHERE fs.fcr_id = w.fcr_id
ORDER BY fs.weight DESC
LIMIT 1)
)
), 0) AS std_fcr
FROM weights w
GROUP BY w.week
ORDER BY w.week ASC
`, filterClause, filterClause)
args := make([]interface{}, 0, len(filterArgs)*2+2)
args = append(args, filterArgs...)
args = append(args, weeks)
args = append(args, filterArgs...)
args = append(args, weeks)
var rows []StandardWeeklyFcrMetric
if err := r.DB().WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
db := r.DB().WithContext(ctx).
Table("production_standard_details AS psd").
Select("psd.week AS week, COALESCE(AVG(psd.standard_fcr), 0) AS std_fcr").
Where("psd.week IN ?", weeks).
Where("psd.production_standard_id IN (?)", standardIDs)
if err := db.Group("psd.week").Order("psd.week ASC").Scan(&rows).Error; err != nil {
return nil, err
}
@@ -510,30 +444,6 @@ func (r *DashboardRepositoryImpl) standardIDSubquery(filters *validation.Dashboa
return db
}
func (r *DashboardRepositoryImpl) standardSourceSubquery(filters *validation.DashboardFilter) *gorm.DB {
db := r.DB().
Table("project_flocks AS pf").
Select("DISTINCT pf.production_standard_id, pf.fcr_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Where("pf.production_standard_id > 0").
Where("pf.fcr_id > 0")
if filters != nil {
if len(filters.FlockIds) > 0 {
db = db.Where("pf.id IN ?", filters.FlockIds)
}
if len(filters.KandangIds) > 0 {
db = db.Where("k.id IN ?", filters.KandangIds)
}
if len(filters.LokasiIds) > 0 {
db = db.Where("k.location_id IN ?", filters.LokasiIds)
}
}
return db
}
func (r *DashboardRepositoryImpl) GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) {
seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType)
if err != nil {
@@ -635,13 +545,19 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte
func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) {
var rows []EggQualityWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx).
Table("recording_eggs AS re").
Select(`
((r.day - 1) / 7 + 1) AS week,
Select(fmt.Sprintf(`
%s AS week,
COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty,
COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty,
COALESCE(SUM(re.qty), 0) AS total_qty`,
COALESCE(SUM(re.qty), 0) AS total_qty`, weekExpr),
utils.FlagTelurUtuh,
utils.FlagTelurPutih,
utils.FlagTelurRetak,
@@ -650,6 +566,7 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
@@ -670,14 +587,21 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) {
var rows []WeeklyEggWeightMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx).
Table("recording_eggs AS re").
Select(`
((r.day - 1) / 7 + 1) AS week,
COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`).
Select(fmt.Sprintf(`
%s AS week,
COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`, weekExpr)).
Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL").
Where("r.day IS NOT NULL AND r.day > 0")
@@ -694,15 +618,22 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s
func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) {
var rows []WeeklyFeedUsageMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx).
Table("recording_stocks AS rs").
Select(`
((r.day - 1) / 7 + 1) AS week,
Select(fmt.Sprintf(`
%s AS week,
COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty,
LOWER(uoms.name) AS uom_name`).
LOWER(uoms.name) AS uom_name`, weekExpr)).
Joins("JOIN recordings AS r ON r.id = rs.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("JOIN uoms ON uoms.id = p.uom_id").
@@ -24,46 +24,81 @@ func NewTransactionController(transactionService service.TransactionService) *Tr
}
func (u *TransactionController) GetAll(c *fiber.Ctx) error {
parseOptionalUint := func(key string) (*uint, error) {
parseUintListParam := func(key string) ([]uint, error) {
raw := strings.TrimSpace(c.Query(key, ""))
if raw == "" {
return nil, nil
}
parsed, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid "+key)
parts := strings.Split(raw, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
if parsed == 0 {
continue
}
ids = append(ids, uint(parsed))
}
if parsed == 0 {
if len(ids) == 0 {
return nil, nil
}
value := uint(parsed)
return &value, nil
return ids, nil
}
bankId, err := parseOptionalUint("bank_id")
if err != nil {
return err
parseStringListParam := func(key string) ([]string, error) {
raw := strings.TrimSpace(c.Query(key, ""))
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
values = append(values, trimmed)
}
if len(values) == 0 {
return nil, nil
}
return values, nil
}
customerId, err := parseOptionalUint("customer_id")
bankIDs, err := parseUintListParam("bank_ids")
if err != nil {
return err
return fiber.NewError(fiber.StatusBadRequest, "Invalid bank_ids")
}
supplierId, err := parseOptionalUint("supplier_id")
customerIDs, err := parseUintListParam("customer_ids")
if err != nil {
return err
return fiber.NewError(fiber.StatusBadRequest, "Invalid customer_ids")
}
supplierIDs, err := parseUintListParam("supplier_ids")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid supplier_ids")
}
transactionTypes, err := parseStringListParam("transaction_types")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_types")
}
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
TransactionType: c.Query("transaction_type", ""),
BankId: bankId,
CustomerId: customerId,
SupplierId: supplierId,
SortDate: c.Query("sort_date", ""),
StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
TransactionTypes: transactionTypes,
BankIDs: bankIDs,
CustomerIDs: customerIDs,
SupplierIDs: supplierIDs,
SortDate: c.Query("sort_date", ""),
StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""),
}
if query.Page < 1 || query.Limit < 1 {
@@ -74,33 +74,59 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
db = db.Joins(
"LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL",
string(utils.PaymentPartyCustomer),
).Joins(
"LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL",
string(utils.PaymentPartySupplier),
).Joins(
"LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL",
)
db = db.Where(
`LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(payment_method, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR
LOWER(COALESCE(notes, '')) LIKE ?`,
like, like, like, like,
LOWER(COALESCE(notes, '')) LIKE ? OR
LOWER(COALESCE(customers.name, '')) LIKE ? OR
LOWER(COALESCE(suppliers.name, '')) LIKE ? OR
LOWER(COALESCE(banks.name, '')) LIKE ?`,
like, like, like, like, like, like, like, like,
)
}
if strings.TrimSpace(params.TransactionType) != "" {
db = db.Where("transaction_type = ?", strings.ToUpper(strings.TrimSpace(params.TransactionType)))
if len(params.TransactionTypes) > 0 {
types := make([]string, 0, len(params.TransactionTypes))
for _, transactionType := range params.TransactionTypes {
normalized := strings.ToUpper(strings.TrimSpace(transactionType))
if normalized == "" {
continue
}
types = append(types, normalized)
}
if len(types) > 0 {
db = db.Where("transaction_type IN ?", types)
}
}
if params.BankId != nil {
db = db.Where("bank_id = ?", *params.BankId)
if len(params.BankIDs) > 0 {
db = db.Where("bank_id IN ?", params.BankIDs)
}
if params.CustomerId != nil && params.SupplierId != nil {
customerIDs := params.CustomerIDs
supplierIDs := params.SupplierIDs
if len(customerIDs) > 0 && len(supplierIDs) > 0 {
db = db.Where(
"(party_type = ? AND party_id = ?) OR (party_type = ? AND party_id = ?)",
string(utils.PaymentPartyCustomer), *params.CustomerId,
string(utils.PaymentPartySupplier), *params.SupplierId,
"(party_type = ? AND party_id IN ?) OR (party_type = ? AND party_id IN ?)",
string(utils.PaymentPartyCustomer), customerIDs,
string(utils.PaymentPartySupplier), supplierIDs,
)
} else if params.CustomerId != nil {
db = db.Where("party_type = ? AND party_id = ?", string(utils.PaymentPartyCustomer), *params.CustomerId)
} else if params.SupplierId != nil {
db = db.Where("party_type = ? AND party_id = ?", string(utils.PaymentPartySupplier), *params.SupplierId)
} else if len(customerIDs) > 0 {
db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartyCustomer), customerIDs)
} else if len(supplierIDs) > 0 {
db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartySupplier), supplierIDs)
}
if startDate != nil {
@@ -1,22 +1,22 @@
package validation
type Create struct {
Name string `json:"name" validate:"required_strict,min=3"`
Name string `json:"name" validate:"required_strict,min=3"`
}
type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
TransactionType string `query:"transaction_type" validate:"omitempty,max=50"`
BankId *uint `query:"bank_id" validate:"omitempty,number,gt=0"`
CustomerId *uint `query:"customer_id" validate:"omitempty,number,gt=0"`
SupplierId *uint `query:"supplier_id" validate:"omitempty,number,gt=0"`
SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"`
BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"`
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"`
SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
}
@@ -2,9 +2,13 @@ package repositories
import (
"context"
"fmt"
"strconv"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AdjustmentStockRepository interface {
@@ -12,6 +16,7 @@ type AdjustmentStockRepository interface {
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB
GenerateSequentialNumber(ctx context.Context, prefix string) (string, error)
}
type adjustmentStockRepositoryImpl struct {
@@ -50,3 +55,71 @@ func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepos
func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB {
return r.db
}
func (r *adjustmentStockRepositoryImpl) GenerateSequentialNumber(ctx context.Context, prefix string) (string, error) {
var values []string
err := r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where(fmt.Sprintf("%s ILIKE ?", "adj_number"), prefix+"%").
Select("adj_number").
Order(fmt.Sprintf("%s DESC", "adj_number")).
Limit(20).
Clauses(clause.Locking{Strength: "UPDATE"}).
Pluck("adj_number", &values).Error
if err != nil {
return "", err
}
next := 1
for _, value := range values {
if number, ok := parseNumericSuffix(value, prefix); ok {
next = number + 1
break
}
}
const maxAttempts = 20
for attempt := 0; attempt < maxAttempts; attempt++ {
candidate := fmt.Sprintf("%s%0*d", prefix, 5, next)
exists, err := r.numberExists(ctx, r.db, candidate)
if err != nil {
return "", err
}
if !exists {
return candidate, nil
}
next++
}
return "", fmt.Errorf("unable to generate unique %s", "adj_number")
}
func (r *adjustmentStockRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, value string) (bool, error) {
var count int64
if err := db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where(fmt.Sprintf("%s = ?", "adj_number"), value).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func parseNumericSuffix(value, prefix string) (int, bool) {
if !strings.HasPrefix(value, prefix) {
return 0, false
}
suffix := strings.TrimPrefix(value, prefix)
if suffix == "" {
return 0, false
}
trimmed := strings.TrimLeft(suffix, "0")
if trimmed == "" {
trimmed = "0"
}
number, err := strconv.Atoi(trimmed)
if err != nil {
return 0, false
}
return number, true
}
@@ -200,6 +200,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
adjustmentStock := &entity.AdjustmentStock{
ProductWarehouseId: productWarehouse.Id,
}
code, err := s.AdjustmentStockRepository.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix)
if err != nil {
return err
}
adjustmentStock.AdjNumber = code
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
@@ -62,6 +62,7 @@ type StockLogDetailDTO struct {
Id uint `json:"id"`
Increase float64 `json:"increase"`
Decrease float64 `json:"decrease"`
Stock float64 `json:"stock"`
LoggableType string `json:"loggable_type"`
LoggableId uint `json:"loggable_id"`
Notes *string `json:"notes"`
@@ -195,6 +196,7 @@ func mapStockLogs(src []entity.StockLog) []StockLogDetailDTO {
Id: log.Id,
Increase: log.Increase,
Decrease: log.Decrease,
Stock: log.Stock,
LoggableType: log.LoggableType,
LoggableId: log.LoggableId,
Notes: notes,
@@ -5,7 +5,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
areaRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
fcrRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto"
flockRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto"
kandangRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
@@ -13,6 +12,7 @@ import (
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
userRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === DTO Structs (ordered) ===
@@ -40,7 +40,7 @@ type ProjectFlockDTO struct {
Category string `json:"category"`
Flock *flockRelationDTO.FlockRelationDTO `json:"flock"`
Area *areaRelationDTO.AreaRelationDTO `json:"area"`
Fcr *fcrRelationDTO.FcrRelationDTO `json:"fcr"`
StandardFcr *float64 `json:"standard_fcr"`
Location *locationRelationDTO.LocationRelationDTO `json:"location"`
}
@@ -97,10 +97,6 @@ func ToAreaDTO(e entity.Area) areaRelationDTO.AreaRelationDTO {
return areaRelationDTO.ToAreaRelationDTO(e)
}
func ToFcrDTO(e entity.Fcr) fcrRelationDTO.FcrRelationDTO {
return fcrRelationDTO.ToFcrRelationDTO(e)
}
func ToLocationDTO(e entity.Location) locationRelationDTO.LocationRelationDTO {
return locationRelationDTO.ToLocationRelationDTO(e)
}
@@ -121,11 +117,6 @@ func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO {
mapped := areaRelationDTO.ToAreaRelationDTO(e.Area)
area = &mapped
}
var fcr *fcrRelationDTO.FcrRelationDTO
if e.Fcr.Id != 0 {
mapped := fcrRelationDTO.ToFcrRelationDTO(e.Fcr)
fcr = &mapped
}
var location *locationRelationDTO.LocationRelationDTO
if e.Location.Id != 0 {
mapped := locationRelationDTO.ToLocationRelationDTO(e.Location)
@@ -137,7 +128,7 @@ func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO {
Category: e.Category,
Flock: flock,
Area: area,
Fcr: fcr,
StandardFcr: resolveProjectFlockStandardFcr(e),
Location: location,
}
}
@@ -222,6 +213,22 @@ func ToChickinListDTOs(e []entity.ProjectChickin) []ChickinListDTO {
return result
}
func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 {
if e.ProductionStandard.Id == 0 || len(e.ProductionStandard.ProductionStandardDetails) == 0 {
return nil
}
week := 1
if e.Category == string(utils.ProjectFlockCategoryLaying) {
week = 18
}
for _, detail := range e.ProductionStandard.ProductionStandardDetails {
if detail.Week == week && detail.StandardFCR != nil {
return detail.StandardFCR
}
}
return nil
}
func ToChickinSimpleDTOs(e []entity.ProjectChickin) []ChickinSimpleDTO {
result := make([]ChickinSimpleDTO, len(e))
for i, r := range e {
@@ -81,7 +81,7 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.Kandang.Pic").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.Area").
Preload("ProjectFlockKandang.ProjectFlock.Fcr").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard.ProductionStandardDetails").
Preload("ProjectFlockKandang.ProjectFlock.Location").
Preload("ProjectFlockKandang.ProjectFlock.Location.Area")
@@ -8,7 +8,6 @@ import (
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto"
areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto"
@@ -31,7 +30,7 @@ type ProjectFlockDTO struct {
projectFlockDTO.ProjectFlockRelationDTO
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"`
Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"`
StandardFcr *float64 `json:"standard_fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
@@ -86,7 +85,7 @@ func toProjectFlockDTO(pf *projectFlockDTO.ProjectFlockListDTO) *ProjectFlockDTO
ProjectFlockRelationDTO: pf.ProjectFlockRelationDTO,
Area: pf.Area,
Category: pf.Category,
Fcr: pf.Fcr,
StandardFcr: pf.StandardFcr,
ProductionStandard: pf.ProductionStandard,
Location: pf.Location,
CreatedUser: pf.CreatedUser,
@@ -6,7 +6,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
@@ -28,7 +27,7 @@ type ProjectFlockListDTO struct {
ProjectFlockRelationDTO
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"`
Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"`
StandardFcr *float64 `json:"standard_fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"`
@@ -99,12 +98,6 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF
areaSummary = &mapped
}
var fcrSummary *fcrDTO.FcrRelationDTO
if e.Fcr.Id != 0 {
mapped := fcrDTO.ToFcrRelationDTO(e.Fcr)
fcrSummary = &mapped
}
var productionStandardSummary *productionStandardDTO.ProductionStandardRelationDTO
if e.ProductionStandard.Id != 0 {
mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProductionStandard)
@@ -129,7 +122,7 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF
Kandangs: kandangSummaries,
ProjectBudgets: ToProjectBudgetDTOs(e.Budgets),
Category: e.Category,
Fcr: fcrSummary,
StandardFcr: resolveProjectFlockStandardFcr(e),
ProductionStandard: productionStandardSummary,
Location: locationSummary,
CreatedAt: e.CreatedAt,
@@ -204,6 +197,22 @@ func createProjectFlockRelationDTO(e entity.ProjectFlock, period int) ProjectFlo
}
}
func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 {
if e.ProductionStandard.Id == 0 || len(e.ProductionStandard.ProductionStandardDetails) == 0 {
return nil
}
week := 1
if e.Category == string(utils.ProjectFlockCategoryLaying) {
week = 18
}
for _, detail := range e.ProductionStandard.ProductionStandardDetails {
if detail.Week == week && detail.StandardFCR != nil {
return detail.StandardFCR
}
}
return nil
}
func ToProjectBudgetDTO(e entity.ProjectBudget) ProjectBudgetDTO {
var nonstockRef *nonstockDTO.NonstockRelationDTO
if e.Nonstock != nil && e.Nonstock.Id != 0 {
@@ -5,7 +5,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto"
@@ -22,7 +21,7 @@ type ProjectFlockWithPivotDTO struct {
ProjectFlockRelationDTO
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"`
Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"`
StandardFcr *float64 `json:"standard_fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
ProductionStandardId uint `json:"production_standard_id"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
@@ -67,10 +66,6 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD
mapped := areaDTO.ToAreaRelationDTO(e.ProjectFlock.Area)
pfLocal.Area = &mapped
}
if e.ProjectFlock.Fcr.Id != 0 {
mapped := fcrDTO.ToFcrRelationDTO(e.ProjectFlock.Fcr)
pfLocal.Fcr = &mapped
}
if e.ProjectFlock.ProductionStandard.Id != 0 {
mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProjectFlock.ProductionStandard)
pfLocal.ProductionStandard = &mapped
@@ -83,6 +78,7 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD
mapped := userDTO.ToUserRelationDTO(e.ProjectFlock.CreatedUser)
pfLocal.CreatedUser = &mapped
}
pfLocal.StandardFcr = resolveProjectFlockStandardFcr(e.ProjectFlock)
for _, k := range e.ProjectFlock.Kandangs {
kb := kandangDTO.ToKandangRelationDTO(k)
@@ -23,7 +23,6 @@ type ProjectflockRepository interface {
GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error)
IdExists(ctx context.Context, id uint) (bool, error)
AreaExists(ctx context.Context, id uint) (bool, error)
FcrExists(ctx context.Context, id uint) (bool, error)
ProductionStandardExists(ctx context.Context, id uint) (bool, error)
LocationExists(ctx context.Context, id uint) (bool, error)
}
@@ -67,8 +66,8 @@ func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm
return db.
Preload("CreatedUser").
Preload("Area").
Preload("Fcr").
Preload("ProductionStandard").
Preload("ProductionStandard.ProductionStandardDetails").
Preload("Location").
Preload("Kandangs").
Preload("KandangHistory").
@@ -134,14 +133,12 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s
likeQuery := "%" + normalized + "%"
return db.
Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id").
Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id").
Joins("LEFT JOIN production_standards ON production_standards.id = project_flocks.production_standard_id").
Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id").
Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by").
Where(`
LOWER(areas.name) LIKE ?
OR LOWER(project_flocks.category) LIKE ?
OR LOWER(fcrs.name) LIKE ?
OR LOWER(production_standards.name) LIKE ?
OR LOWER(locations.name) LIKE ?
OR LOWER(locations.address) LIKE ?
@@ -172,7 +169,6 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s
likeQuery,
likeQuery,
likeQuery,
likeQuery,
)
}
@@ -184,10 +180,6 @@ func (r *ProjectflockRepositoryImpl) AreaExists(ctx context.Context, id uint) (b
return repository.Exists[entity.Area](ctx, r.DB(), id)
}
func (r *ProjectflockRepositoryImpl) FcrExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.Fcr](ctx, r.DB(), id)
}
func (r *ProjectflockRepositoryImpl) ProductionStandardExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.ProductionStandard](ctx, r.DB(), id)
}
@@ -117,10 +117,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex
Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\"").
Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\"").
Preload("ProjectFlock").
Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location").
Preload("ProjectFlock.CreatedUser").
Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails").
Preload("ProjectFlock.Kandangs").
Preload("ProjectFlock.KandangHistory").
Preload("Kandang").
@@ -208,10 +208,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context.
Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\"").
Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\"").
Preload("ProjectFlock").
Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location").
Preload("ProjectFlock.CreatedUser").
Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails").
Preload("ProjectFlock.Kandangs").
Preload("ProjectFlock.KandangHistory").
Preload("Kandang").
@@ -324,10 +324,10 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint
record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx).
Preload("ProjectFlock").
Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location").
Preload("ProjectFlock.CreatedUser").
Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails").
Preload("ProjectFlock.Kandangs").
Preload("ProjectFlock.KandangHistory").
Preload("Kandang").
@@ -347,10 +347,10 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont
if err := r.db.WithContext(ctx).
Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID).
Preload("ProjectFlock").
Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location").
Preload("ProjectFlock.CreatedUser").
Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails").
Preload("ProjectFlock.Kandangs").
Preload("ProjectFlock.KandangHistory").
Preload("Kandang").
@@ -282,7 +282,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists},
commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: s.Repository.FcrExists},
commonSvc.RelationCheck{Name: "Production Standard", ID: &req.ProductionStandardId, Exists: s.Repository.ProductionStandardExists},
commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists},
); err != nil {
@@ -334,7 +333,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
createBody := &entity.ProjectFlock{
AreaId: req.AreaId,
Category: cat,
FcrId: req.FcrId,
ProductionStandardId: req.ProductionStandardId,
LocationId: req.LocationId,
CreatedBy: actorID,
@@ -4,7 +4,6 @@ type Create struct {
FlockName string `json:"flock_name" validate:"required_strict"`
AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"`
Category string `json:"category" validate:"required_strict"`
FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"`
ProductionStandardId uint `json:"production_standard_id" validate:"required_strict,number,gt=0"`
LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"`
KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"`
@@ -1,6 +1,7 @@
package dto
import (
"math"
"strings"
"time"
@@ -73,7 +74,9 @@ type RecordingRelationDTO struct {
RecordDatetime time.Time `json:"record_datetime"`
Day int `json:"day"`
TotalDepletionQty float64 `json:"total_depletion_qty"`
TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"`
CumDepletionRate float64 `json:"cum_depletion_rate"`
DepletionRate float64 `json:"depletion_rate"`
CumIntake int `json:"cum_intake"`
FcrValue float64 `json:"fcr_value"`
HenDay float64 `json:"hen_day"`
@@ -230,7 +233,9 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day),
TotalDepletionQty: floatValue(e.TotalDepletionQty),
CumDepletionRate: floatValue(e.CumDepletionRate),
TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty),
CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2),
DepletionRate: roundFloatValue(e.DepletionRate, 2),
CumIntake: intValue(e.CumIntake),
FcrValue: floatValue(e.FcrValue),
HenDay: floatValue(e.HenDay),
@@ -275,10 +280,10 @@ func toRecordingProjectFlockDTO(e entity.Recording) RecordingProjectFlockDTO {
}
}
if pfk.ProjectFlock.Fcr.Id != 0 || e.StandardFcr != nil {
if pfk.ProjectFlock.ProductionStandard.Id != 0 || e.StandardFcr != nil {
result.Fcr = &RecordingFcrDTO{
Id: pfk.ProjectFlock.Fcr.Id,
Name: pfk.ProjectFlock.Fcr.Name,
Id: pfk.ProjectFlock.ProductionStandard.Id,
Name: pfk.ProjectFlock.ProductionStandard.Name,
FcrStd: floatValue(e.StandardFcr),
}
}
@@ -426,6 +431,17 @@ func floatValue(value *float64) float64 {
return *value
}
func roundFloatValue(value *float64, places int) float64 {
if value == nil {
return 0
}
if places <= 0 {
return math.Round(*value)
}
factor := math.Pow(10, float64(places))
return math.Round(*value*factor) / factor
}
func intValue(value *int) int {
if value == nil {
return 0
@@ -39,13 +39,13 @@ type RecordingRepository interface {
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error)
GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error)
FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error)
GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error)
GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error)
GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error)
GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error)
GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error)
GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error)
GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error)
GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error)
@@ -91,7 +91,7 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.Kandang.Location").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard").
Preload("ProjectFlockKandang.ProjectFlock.Fcr").
// Preload("ProjectFlockKandang.ProjectFlock.Fcr").
Preload("Depletions").
Preload("Depletions.ProductWarehouse").
Preload("Depletions.ProductWarehouse.Product").
@@ -314,6 +314,23 @@ func (r *RecordingRepositoryImpl) SumRecordingDepletions(tx *gorm.DB, recordingI
return result, nil
}
func (r *RecordingRepositoryImpl) GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) {
if projectFlockKandangId == 0 || recordTime.IsZero() {
return 0, nil
}
var total float64
err := tx.
Table("recording_depletions rd").
Select("COALESCE(SUM(rd.qty),0)").
Joins("JOIN recordings r ON r.id = rd.recording_id").
Where("r.project_flock_kandangs_id = ?", projectFlockKandangId).
Where("r.record_datetime <= ?", recordTime).
Where("r.deleted_at IS NULL").
Scan(&total).Error
return total, err
}
func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) {
if currentDay <= 1 {
return nil, nil
@@ -430,34 +447,6 @@ func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang(
Scan(&result).Error
return result, err
}
func (r *RecordingRepositoryImpl) GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) {
if fcrId == 0 || currentWeightKg <= 0 {
return 0, false, nil
}
var standard entity.FcrStandard
err := tx.
Where("fcr_id = ? AND weight >= ?", fcrId, currentWeightKg).
Order("weight ASC").
First(&standard).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = tx.
Where("fcr_id = ?", fcrId).
Order("weight DESC").
First(&standard).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, false, nil
}
}
if err != nil {
return 0, false, err
}
return standard.FcrNumber, true, nil
}
func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) {
// Body-weight tracking is removed; keep stub for report compatibility.
return 0, 0, nil
@@ -21,7 +21,6 @@ import (
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
"github.com/go-playground/validator/v10"
@@ -40,14 +39,6 @@ type RecordingService interface {
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error)
}
type RecordingFIFOIntegrationService interface {
ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
}
var recordingStockUsableKey = fifo.UsableKeyRecordingStock
var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion
type recordingService struct {
Log *logrus.Logger
Validate *validator.Validate
@@ -89,21 +80,6 @@ func NewRecordingService(
}
}
func NewRecordingFIFOIntegrationService(
repo repository.RecordingRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
fifoSvc commonSvc.FifoService,
stockLogRepo rStockLogs.StockLogRepository,
) RecordingFIFOIntegrationService {
return &recordingService{
Log: utils.Log,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
}
}
func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -152,6 +128,12 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
if err := s.attachProductionStandards(c.Context(), recordings); err != nil {
return nil, 0, err
}
if err := s.attachCumulativeDepletions(c.Context(), recordings); err != nil {
return nil, 0, err
}
if err := s.attachDepletionRates(c.Context(), recordings); err != nil {
return nil, 0, err
}
return recordings, total, nil
}
@@ -176,6 +158,12 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro
if err := s.attachProductionStandard(c.Context(), recording); err != nil {
return nil, err
}
if err := s.attachCumulativeDepletion(c.Context(), recording); err != nil {
return nil, err
}
if err := s.attachDepletionRate(c.Context(), recording); err != nil {
return nil, err
}
return recording, nil
}
@@ -347,7 +335,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
}
var warehouseDeltas map[uint]float64
warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)
if s.FifoSvc != nil {
// FIFO replenish already adjusts egg warehouse quantities.
warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, nil)
} else {
warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil {
s.Log.Errorf("Failed to adjust product warehouses: %+v", err)
return err
@@ -529,39 +522,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := ensureRecordingEggsUnused(existingEggs); err != nil {
return err
}
if s.StockLogRepo != nil {
note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
logs := make([]*entity.StockLog, 0, len(existingEggs))
for _, egg := range existingEggs {
if egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
latestStockLog := &entity.StockLog{}
if len(stockLogs) > 0 {
latestStockLog = stockLogs[0]
} else {
latestStockLog.Stock = 0
}
logs = append(logs, &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Decrease: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: recordingEntity.Id,
Notes: note,
Stock: latestStockLog.Stock - float64(egg.Qty),
})
}
if len(logs) > 0 {
if err := s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil); err != nil {
return err
}
}
note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil {
return err
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err)
@@ -818,40 +781,6 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
})
}
func (s *recordingService) logRecordingEggRollback(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
note string,
actorID uint,
) error {
if len(eggs) == 0 || s.StockLogRepo == nil {
return nil
}
if strings.TrimSpace(note) == "" || actorID == 0 {
return nil
}
for _, egg := range eggs {
if egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
log := &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Decrease: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: egg.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
return nil
}
// === Persistence Helpers ===
func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error {
@@ -891,381 +820,6 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v
return nil
}
func (s *recordingService) consumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, stock := range stocks {
if stock.Id == 0 {
continue
}
var desired float64
if stock.UsageQty != nil {
desired = *stock.UsageQty
}
var pending float64
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
desiredTotal := desired + pending
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
ProductWarehouseID: stock.ProductWarehouseId,
Quantity: desiredTotal,
AllowPending: true,
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err)
return err
}
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err
}
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
logDecrease += result.PendingQuantity
}
if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Decrease: logDecrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
func (s *recordingService) consumeRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 {
continue
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
sourceWarehouseID = *depletion.SourceProductWarehouseId
}
if sourceWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion")
}
desired := depletion.Qty + depletion.PendingQty
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: recordingDepletionUsableKey,
UsableID: depletion.Id,
ProductWarehouseID: sourceWarehouseID,
Quantity: desired,
AllowPending: false,
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil {
return err
}
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
logDecrease += result.PendingQuantity
}
if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: sourceWarehouseID,
CreatedBy: actorID,
Decrease: logDecrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
destDelta := depletion.Qty + depletion.PendingQty
if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
if depletion.ProductWarehouseId == sourceWarehouseID {
continue
}
log := &entity.StockLog{
ProductWarehouseId: depletion.ProductWarehouseId,
CreatedBy: actorID,
Increase: destDelta,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
func (s *recordingService) ConsumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID)
}
func (s *recordingService) releaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, stock := range stocks {
if stock.Id == 0 {
continue
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err)
return err
}
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err
}
if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Increase: *stock.UsageQty,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
func (s *recordingService) releaseRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 {
continue
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
sourceWarehouseID = *depletion.SourceProductWarehouseId
}
if sourceWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion")
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingDepletionUsableKey,
UsableID: depletion.Id,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
return err
}
logIncrease := depletion.Qty
if depletion.PendingQty > 0 {
logIncrease += depletion.PendingQty
}
if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: sourceWarehouseID,
CreatedBy: actorID,
Increase: logIncrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
destDelta := depletion.Qty + depletion.PendingQty
if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
if depletion.ProductWarehouseId == sourceWarehouseID {
continue
}
log := &entity.StockLog{
ProductWarehouseId: depletion.ProductWarehouseId,
CreatedBy: actorID,
Decrease: destDelta,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
func (s *recordingService) ReleaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID)
}
func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
if projectFlockKandangID == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
@@ -1356,212 +910,6 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context,
return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx })
}
func (s *recordingService) replenishRecordingEggs(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
note string,
actorID uint,
) error {
if len(eggs) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, egg := range eggs {
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id,
ProductWarehouseID: egg.ProductWarehouseId,
Quantity: float64(egg.Qty),
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err)
return err
}
if strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Increase: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: egg.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
type desiredStock struct {
Usage float64
Pending float64
}
type desiredDepletion struct {
Qty float64
Pending float64
}
func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock {
desired := make([]desiredStock, len(stocks))
for i := range stocks {
if stocks[i].UsageQty != nil {
desired[i].Usage = *stocks[i].UsageQty
}
if stocks[i].PendingQty != nil {
desired[i].Pending = *stocks[i].PendingQty
}
if !enabled {
continue
}
zero := 0.0
stocks[i].UsageQty = &zero
stocks[i].PendingQty = &zero
}
return desired
}
func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) {
if !enabled {
return
}
for i := range stocks {
if i >= len(desired) {
break
}
usage := desired[i].Usage
pending := desired[i].Pending
stocks[i].UsageQty = &usage
stocks[i].PendingQty = &pending
}
}
func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion {
desired := make([]desiredDepletion, len(depletions))
for i := range depletions {
desired[i].Qty = depletions[i].Qty
desired[i].Pending = depletions[i].PendingQty
if !enabled {
continue
}
depletions[i].Qty = 0
depletions[i].PendingQty = 0
}
return desired
}
func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) {
if !enabled {
return
}
for i := range depletions {
if i >= len(desired) {
break
}
depletions[i].Qty = desired[i].Qty
depletions[i].PendingQty = desired[i].Pending
}
}
func (s *recordingService) syncRecordingStocks(
ctx context.Context,
tx *gorm.DB,
recordingID uint,
existing []entity.RecordingStock,
incoming []validation.Stock,
note string,
actorID uint,
) error {
if s.FifoSvc == nil {
if err := s.Repository.DeleteStocks(tx, recordingID); err != nil {
return err
}
mapped := recordingutil.MapStocks(recordingID, incoming)
return s.Repository.CreateStocks(tx, mapped)
}
existingByWarehouse := make(map[uint][]entity.RecordingStock)
for _, stock := range existing {
existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock)
}
stocksToConsume := make([]entity.RecordingStock, 0, len(incoming))
for _, item := range incoming {
list := existingByWarehouse[item.ProductWarehouseId]
var stock entity.RecordingStock
if len(list) > 0 {
stock = list[0]
existingByWarehouse[item.ProductWarehouseId] = list[1:]
} else {
zero := 0.0
stock = entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId,
UsageQty: &zero,
PendingQty: &zero,
}
if err := tx.Create(&stock).Error; err != nil {
return err
}
}
desired := item.Qty
stock.UsageQty = &desired
zero := 0.0
stock.PendingQty = &zero
stocksToConsume = append(stocksToConsume, stock)
}
var leftovers []entity.RecordingStock
for _, list := range existingByWarehouse {
leftovers = append(leftovers, list...)
}
if len(leftovers) > 0 {
if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil {
return err
}
ids := make([]uint, 0, len(leftovers))
for _, stock := range leftovers {
if stock.Id != 0 {
ids = append(ids, stock.Id)
}
}
if len(ids) > 0 {
if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil {
return err
}
}
}
if len(stocksToConsume) == 0 {
return nil
}
return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID)
}
type eggTotals struct {
Qty int
Weight float64
@@ -1690,12 +1038,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
return fmt.Errorf("getPreviousRecording: %w", err)
}
var prevCumDepletionQty float64
var prevCumIntake float64
if prevRecording != nil {
if prevRecording.TotalDepletionQty != nil {
prevCumDepletionQty = *prevRecording.TotalDepletionQty
}
if prevRecording.CumIntake != nil {
prevCumIntake = float64(*prevRecording.CumIntake)
}
@@ -1727,29 +1071,51 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
}
currentDepletion := float64(totalDepletionQty)
cumDepletionQty := prevCumDepletionQty + currentDepletion
cumDepletionQty, err := s.Repository.GetCumulativeDepletionByProjectFlockKandangUntil(tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
if err != nil {
return fmt.Errorf("getCumulativeDepletionByProjectFlockKandangUntil: %w", err)
}
updates := map[string]any{
"total_depletion_qty": cumDepletionQty,
"total_depletion_qty": currentDepletion,
}
recording.TotalDepletionQty = &cumDepletionQty
recording.TotalDepletionQty = &currentDepletion
recording.TotalDepletionCumQty = &cumDepletionQty
var remainingChick float64
if totalChick > 0 {
totalChickFloat := float64(totalChick)
remainingChick = totalChickFloat - cumDepletionQty
if remainingChick < 0 {
remainingChick = 0
}
updates["total_chick_qty"] = remainingChick
recording.TotalChickQty = &remainingChick
if s.FifoSvc != nil {
// totalChick already represents available qty (total_qty - total_used_qty).
remainingChick = totalChickFloat
updates["total_chick_qty"] = remainingChick
recording.TotalChickQty = &remainingChick
cumRate := 0.0
if totalChickFloat > 0 {
cumRate = (cumDepletionQty / totalChickFloat) * 100
baseChick := initialChickin
if baseChick <= 0 {
baseChick = totalChickFloat + cumDepletionQty
}
cumRate := 0.0
if baseChick > 0 {
cumRate = (cumDepletionQty / baseChick) * 100
}
updates["cum_depletion_rate"] = cumRate
recording.CumDepletionRate = &cumRate
} else {
remainingChick = totalChickFloat - cumDepletionQty
if remainingChick < 0 {
remainingChick = 0
}
updates["total_chick_qty"] = remainingChick
recording.TotalChickQty = &remainingChick
cumRate := 0.0
if totalChickFloat > 0 {
cumRate = (cumDepletionQty / totalChickFloat) * 100
}
updates["cum_depletion_rate"] = cumRate
recording.CumDepletionRate = &cumRate
}
updates["cum_depletion_rate"] = cumRate
recording.CumDepletionRate = &cumRate
} else {
updates["total_chick_qty"] = gorm.Expr("NULL")
updates["cum_depletion_rate"] = gorm.Expr("NULL")
@@ -1757,6 +1123,9 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
recording.CumDepletionRate = nil
}
depletionRate := computeDepletionRate(prevRecording, currentDepletion, totalChick)
recording.DepletionRate = &depletionRate
var feedIntake float64
if remainingChick > 0 && usageInGrams > 0 {
feedIntake = (usageInGrams / remainingChick) * 1000
@@ -1847,6 +1216,81 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
return nil
}
func computeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 {
base := 0.0
if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 {
base = *prevRecording.TotalChickQty
} else if totalChick > 0 {
// totalChick is already remaining after today's depletion; add back current to approximate previous population.
base = float64(totalChick) + currentDepletion
}
if base <= 0 {
return 0
}
return (currentDepletion / base) * 100
}
func (s *recordingService) attachCumulativeDepletion(ctx context.Context, recording *entity.Recording) error {
if recording == nil || recording.ProjectFlockKandangId == 0 || recording.RecordDatetime.IsZero() {
return nil
}
total, err := s.Repository.GetCumulativeDepletionByProjectFlockKandangUntil(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId, recording.RecordDatetime)
if err != nil {
return err
}
recording.TotalDepletionCumQty = &total
return nil
}
func (s *recordingService) attachCumulativeDepletions(ctx context.Context, recordings []entity.Recording) error {
if len(recordings) == 0 {
return nil
}
for i := range recordings {
if err := s.attachCumulativeDepletion(ctx, &recordings[i]); err != nil {
return err
}
}
return nil
}
func (s *recordingService) attachDepletionRate(ctx context.Context, recording *entity.Recording) error {
if recording == nil {
return nil
}
current := 0.0
if recording.TotalDepletionQty != nil {
current = *recording.TotalDepletionQty
}
day := 0
if recording.Day != nil {
day = *recording.Day
}
prev, err := s.Repository.FindPreviousRecording(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId, day)
if err != nil {
return err
}
totalChick, err := s.Repository.GetTotalChick(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId)
if err != nil {
return err
}
rate := computeDepletionRate(prev, current, totalChick)
recording.DepletionRate = &rate
return nil
}
func (s *recordingService) attachDepletionRates(ctx context.Context, recordings []entity.Recording) error {
if len(recordings) == 0 {
return nil
}
for i := range recordings {
if err := s.attachDepletionRate(ctx, &recordings[i]); err != nil {
return err
}
}
return nil
}
func (s *recordingService) createRecordingApproval(
ctx context.Context,
db *gorm.DB,
@@ -1999,16 +1443,17 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e
var standard productionStandardValues
var standardFcr *float64
if category == string(utils.ProjectFlockCategoryLaying) {
detail, err := standardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if detail != nil {
standard.HenDay = detail.TargetHenDayProduction
standard.HenHouse = detail.TargetHenHouseProduction
standard.EggWeight = detail.TargetEggWeight
standard.EggMass = detail.TargetEggMass
detail, err := standardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if detail != nil {
standard.HenDay = detail.TargetHenDayProduction
standard.HenHouse = detail.TargetHenHouseProduction
standard.EggWeight = detail.TargetEggWeight
standard.EggMass = detail.TargetEggMass
if detail.StandardFCR != nil {
standardFcr = detail.StandardFCR
}
}
@@ -2019,21 +1464,6 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e
if growthDetail != nil {
standard.FeedIntake = growthDetail.FeedIntake
standard.MaxDepletion = growthDetail.MaxDepletion
if category == string(utils.ProjectFlockCategoryLaying) && growthDetail.TargetMeanBw != nil && item.ProjectFlockKandang.ProjectFlock.FcrId > 0 {
targetWeight := *growthDetail.TargetMeanBw
if targetWeight > 10 {
targetWeight = targetWeight / 1000
}
if targetWeight > 0 {
fcrStd, ok, err := s.Repository.GetFcrStandardNumber(db, item.ProjectFlockKandang.ProjectFlock.FcrId, targetWeight)
if err != nil {
return err
}
if ok {
standardFcr = &fcrStd
}
}
}
}
item.StandardHenDay = standard.HenDay
@@ -0,0 +1,703 @@
package service
import (
"context"
"errors"
"strings"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type RecordingFIFOIntegrationService interface {
ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
}
var recordingStockUsableKey = fifo.UsableKeyRecordingStock
var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion
func NewRecordingFIFOIntegrationService(
repo repository.RecordingRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
fifoSvc commonSvc.FifoService,
stockLogRepo rStockLogs.StockLogRepository,
) RecordingFIFOIntegrationService {
return &recordingService{
Log: utils.Log,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
}
}
func (s *recordingService) consumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, stock := range stocks {
if stock.Id == 0 {
continue
}
var desired float64
if stock.UsageQty != nil {
desired = *stock.UsageQty
}
var pending float64
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
desiredTotal := desired + pending
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
ProductWarehouseID: stock.ProductWarehouseId,
Quantity: desiredTotal,
AllowPending: true,
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err)
return err
}
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err
}
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
logDecrease += result.PendingQuantity
}
if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Decrease: logDecrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
func (s *recordingService) consumeRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 {
continue
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
sourceWarehouseID = *depletion.SourceProductWarehouseId
}
if sourceWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion")
}
desired := depletion.Qty + depletion.PendingQty
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: recordingDepletionUsableKey,
UsableID: depletion.Id,
ProductWarehouseID: sourceWarehouseID,
Quantity: desired,
AllowPending: false,
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil {
return err
}
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
logDecrease += result.PendingQuantity
}
if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: sourceWarehouseID,
CreatedBy: actorID,
Decrease: logDecrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
destDelta := depletion.Qty + depletion.PendingQty
if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
if depletion.ProductWarehouseId == sourceWarehouseID {
continue
}
log := &entity.StockLog{
ProductWarehouseId: depletion.ProductWarehouseId,
CreatedBy: actorID,
Increase: destDelta,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
func (s *recordingService) ConsumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID)
}
func (s *recordingService) releaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, stock := range stocks {
if stock.Id == 0 {
continue
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err)
return err
}
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err
}
if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Increase: *stock.UsageQty,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
func (s *recordingService) releaseRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 {
continue
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
sourceWarehouseID = *depletion.SourceProductWarehouseId
}
if sourceWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion")
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingDepletionUsableKey,
UsableID: depletion.Id,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
return err
}
logIncrease := depletion.Qty
if depletion.PendingQty > 0 {
logIncrease += depletion.PendingQty
}
if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: sourceWarehouseID,
CreatedBy: actorID,
Increase: logIncrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
destDelta := depletion.Qty + depletion.PendingQty
if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
if depletion.ProductWarehouseId == sourceWarehouseID {
continue
}
log := &entity.StockLog{
ProductWarehouseId: depletion.ProductWarehouseId,
CreatedBy: actorID,
Decrease: destDelta,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
func (s *recordingService) ReleaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID)
}
func (s *recordingService) logRecordingEggUsage(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
note string,
actorID uint,
) error {
if len(eggs) == 0 || s.StockLogRepo == nil {
return nil
}
if strings.TrimSpace(note) == "" || actorID == 0 {
return nil
}
logs := make([]*entity.StockLog, 0, len(eggs))
for _, egg := range eggs {
if egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
latestStockLog := &entity.StockLog{}
if len(stockLogs) > 0 {
latestStockLog = stockLogs[0]
} else {
latestStockLog.Stock = 0
}
logs = append(logs, &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Decrease: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: egg.RecordingId,
Notes: note,
Stock: latestStockLog.Stock - float64(egg.Qty),
})
}
if len(logs) == 0 {
return nil
}
return s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil)
}
func (s *recordingService) logRecordingEggRollback(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
note string,
actorID uint,
) error {
if len(eggs) == 0 || s.StockLogRepo == nil {
return nil
}
if strings.TrimSpace(note) == "" || actorID == 0 {
return nil
}
for _, egg := range eggs {
if egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
log := &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Decrease: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: egg.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
return nil
}
func (s *recordingService) replenishRecordingEggs(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
note string,
actorID uint,
) error {
if len(eggs) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, egg := range eggs {
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id,
ProductWarehouseID: egg.ProductWarehouseId,
Quantity: float64(egg.Qty),
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err)
return err
}
if strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Increase: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: egg.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
type desiredStock struct {
Usage float64
Pending float64
}
type desiredDepletion struct {
Qty float64
Pending float64
}
func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock {
desired := make([]desiredStock, len(stocks))
for i := range stocks {
if stocks[i].UsageQty != nil {
desired[i].Usage = *stocks[i].UsageQty
}
if stocks[i].PendingQty != nil {
desired[i].Pending = *stocks[i].PendingQty
}
if !enabled {
continue
}
zero := 0.0
stocks[i].UsageQty = &zero
stocks[i].PendingQty = &zero
}
return desired
}
func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) {
if !enabled {
return
}
for i := range stocks {
if i >= len(desired) {
break
}
usage := desired[i].Usage
pending := desired[i].Pending
stocks[i].UsageQty = &usage
stocks[i].PendingQty = &pending
}
}
func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion {
desired := make([]desiredDepletion, len(depletions))
for i := range depletions {
desired[i].Qty = depletions[i].Qty
desired[i].Pending = depletions[i].PendingQty
if !enabled {
continue
}
depletions[i].Qty = 0
depletions[i].PendingQty = 0
}
return desired
}
func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) {
if !enabled {
return
}
for i := range depletions {
if i >= len(desired) {
break
}
depletions[i].Qty = desired[i].Qty
depletions[i].PendingQty = desired[i].Pending
}
}
func (s *recordingService) syncRecordingStocks(
ctx context.Context,
tx *gorm.DB,
recordingID uint,
existing []entity.RecordingStock,
incoming []validation.Stock,
note string,
actorID uint,
) error {
if s.FifoSvc == nil {
if err := s.Repository.DeleteStocks(tx, recordingID); err != nil {
return err
}
mapped := recordingutil.MapStocks(recordingID, incoming)
return s.Repository.CreateStocks(tx, mapped)
}
existingByWarehouse := make(map[uint][]entity.RecordingStock)
for _, stock := range existing {
existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock)
}
stocksToConsume := make([]entity.RecordingStock, 0, len(incoming))
for _, item := range incoming {
list := existingByWarehouse[item.ProductWarehouseId]
var stock entity.RecordingStock
if len(list) > 0 {
stock = list[0]
existingByWarehouse[item.ProductWarehouseId] = list[1:]
} else {
zero := 0.0
stock = entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId,
UsageQty: &zero,
PendingQty: &zero,
}
if err := tx.Create(&stock).Error; err != nil {
return err
}
}
desired := item.Qty
stock.UsageQty = &desired
zero := 0.0
stock.PendingQty = &zero
stocksToConsume = append(stocksToConsume, stock)
}
var leftovers []entity.RecordingStock
for _, list := range existingByWarehouse {
leftovers = append(leftovers, list...)
}
if len(leftovers) > 0 {
if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil {
return err
}
ids := make([]uint, 0, len(leftovers))
for _, stock := range leftovers {
if stock.Id != 0 {
ids = append(ids, stock.Id)
}
}
if len(ids) > 0 {
if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil {
return err
}
}
}
if len(stocksToConsume) == 0 {
return nil
}
return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID)
}
+58 -6
View File
@@ -1,6 +1,7 @@
package dto
import (
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -24,12 +25,17 @@ type PurchaseRelationDTO struct {
type PurchaseListDTO struct {
PurchaseRelationDTO
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
DueDate *time.Time `json:"due_date"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
DueDate *time.Time `json:"due_date"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
RequesterName string `json:"requester_name"`
PoExpedition []string `json:"po_expedition"`
Products []productDTO.ProductRelationDTO `json:"products"`
Location *locationDTO.LocationRelationDTO `json:"location"`
Area *areaDTO.AreaRelationDTO `json:"area"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
}
type PurchaseDetailDTO struct {
@@ -146,6 +152,10 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
mapped := userDTO.ToUserRelationDTO(p.CreatedUser)
createdUser = &mapped
}
requesterName := ""
if createdUser != nil {
requesterName = createdUser.Name
}
var latestApproval *approvalDTO.ApprovalRelationDTO
if p.LatestApproval != nil && p.LatestApproval.Id != 0 {
@@ -153,11 +163,53 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
latestApproval = &mapped
}
var (
poExpedition []string
location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO
)
productMap := make(map[uint]productDTO.ProductRelationDTO)
expeditionRefSet := make(map[string]struct{})
for i := range p.Items {
item := p.Items[i]
if item.Product != nil && item.Product.Id != 0 {
if _, exists := productMap[item.Product.Id]; !exists {
productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product)
}
}
if item.ExpenseNonstock != nil && item.ExpenseNonstock.Expense != nil {
ref := strings.TrimSpace(item.ExpenseNonstock.Expense.ReferenceNumber)
if ref != "" {
if _, exists := expeditionRefSet[ref]; !exists {
expeditionRefSet[ref] = struct{}{}
poExpedition = append(poExpedition, ref)
}
}
}
if location == nil && item.Warehouse != nil && item.Warehouse.Location != nil && item.Warehouse.Location.Id != 0 {
loc := locationDTO.ToLocationRelationDTO(*item.Warehouse.Location)
location = &loc
}
if area == nil && item.Warehouse != nil && item.Warehouse.Area.Id != 0 {
ar := areaDTO.ToAreaRelationDTO(item.Warehouse.Area)
area = &ar
}
}
products := make([]productDTO.ProductRelationDTO, 0, len(productMap))
for _, prod := range productMap {
products = append(products, prod)
}
return PurchaseListDTO{
PurchaseRelationDTO: ToPurchaseRelationDTO(&p),
Supplier: supplier,
DueDate: p.DueDate,
CreatedUser: createdUser,
RequesterName: requesterName,
PoExpedition: poExpedition,
Products: products,
Location: location,
Area: area,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
LatestApproval: latestApproval,
@@ -51,7 +51,7 @@ type ReceivePurchaseItemRequest struct {
type ReceivePurchaseRequest struct {
Action string `form:"action" json:"action" validate:"required,oneof=APPROVED REJECTED"`
Items []ReceivePurchaseItemRequest `form:"items" json:"items" validate:"min=1,dive"`
Items []ReceivePurchaseItemRequest `form:"items" json:"items" validate:"omitempty,dive"`
TravelDocuments []*multipart.FileHeader `form:"travel_documents" json:"-" validate:"omitempty,dive"`
Notes *string `form:"notes" json:"notes,omitempty" validate:"omitempty,max=500"`
}
@@ -324,6 +324,7 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error {
Page: ctx.QueryInt("page", 1),
Limit: ctx.QueryInt("limit", 10),
CustomerIDs: customerIDs,
FilterBy: strings.ToUpper(ctx.Query("filter_by", "")),
StartDate: ctx.Query("start_date", ""),
EndDate: ctx.Query("end_date", ""),
}
@@ -6,6 +6,7 @@ import (
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/fifo"
"gorm.io/gorm"
)
@@ -208,6 +209,75 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
}
}
feedRows := make([]struct {
ProjectFlockKandangID uint
FeedCost float64
SupplierID *uint
SupplierName *string
SupplierAlias *string
}, 0)
feedQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
r.project_flock_kandangs_id AS project_flock_kandang_id,
s.id AS supplier_id,
s.name AS supplier_name,
s.alias AS supplier_alias`).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("f.name = ?", utils.FlagPakan).
Group("r.project_flock_kandangs_id, s.id, s.name, s.alias")
if err := feedQuery.Scan(&feedRows).Error; err != nil {
return nil, nil, err
}
feedSuppliers := make([]HppPerKandangSupplierRow, 0)
feedSeen := make(map[uint]map[uint]bool)
for _, feed := range feedRows {
entry, ok := costMap[feed.ProjectFlockKandangID]
if !ok {
rows = append(rows, HppPerKandangCostRow{
ProjectFlockKandangID: feed.ProjectFlockKandangID,
})
entry = &rows[len(rows)-1]
costMap[feed.ProjectFlockKandangID] = entry
}
entry.FeedCost += feed.FeedCost
if feed.SupplierID != nil {
if feedSeen[feed.ProjectFlockKandangID] == nil {
feedSeen[feed.ProjectFlockKandangID] = make(map[uint]bool)
}
if !feedSeen[feed.ProjectFlockKandangID][*feed.SupplierID] {
feedSeen[feed.ProjectFlockKandangID][*feed.SupplierID] = true
supplierName := ""
if feed.SupplierName != nil {
supplierName = *feed.SupplierName
}
supplierAlias := ""
if feed.SupplierAlias != nil {
supplierAlias = *feed.SupplierAlias
}
feedSuppliers = append(feedSuppliers, HppPerKandangSupplierRow{
ProjectFlockKandangID: feed.ProjectFlockKandangID,
SupplierID: *feed.SupplierID,
SupplierName: supplierName,
SupplierAlias: supplierAlias,
Category: "FEED",
})
}
}
}
docSuppliers = append(docSuppliers, feedSuppliers...)
return rows, docSuppliers, nil
}
@@ -31,9 +31,25 @@ func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang(
return []entity.Recording{}, 0, nil
}
latestApproval := r.db.WithContext(ctx).
Table("approvals AS a").
Select("a.approvable_id, a.action, a.step_number").
Joins(`
JOIN (
SELECT approvable_id, MAX(action_at) AS latest_action_at
FROM approvals
WHERE approvable_type = ?
GROUP BY approvable_id
) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`,
string(utils.ApprovalWorkflowRecording),
)
countQuery := r.db.WithContext(ctx).
Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangID)
Joins("JOIN (?) AS la ON la.approvable_id = recordings.id", latestApproval).
Where("project_flock_kandangs_id = ?", projectFlockKandangID).
Where("la.step_number = ?", utils.RecordingStepDisetujui).
Where("la.action = ?", string(entity.ApprovalActionApproved))
var total int64
if err := countQuery.Count(&total).Error; err != nil {
@@ -59,7 +75,10 @@ func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang(
dataQuery := r.db.WithContext(ctx).
Model(&entity.Recording{}).
Joins("JOIN (?) AS la ON la.approvable_id = recordings.id", latestApproval).
Where("project_flock_kandangs_id = ?", projectFlockKandangID).
Where("la.step_number = ?", utils.RecordingStepDisetujui).
Where("la.action = ?", string(entity.ApprovalActionApproved)).
Preload("Eggs", func(db *gorm.DB) *gorm.DB {
return db.Select("recording_eggs.*, f.name AS product_flag_name").
Joins("LEFT JOIN product_warehouses pw ON pw.id = recording_eggs.product_warehouse_id").
@@ -398,6 +398,9 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation.
if detail != nil && detail.TargetMeanBw != nil {
weeklyResults[i].StdBw = *detail.TargetMeanBw
}
if detail != nil {
weeklyResults[i].DepStd = valueOrZero(detail.MaxDepletion)
}
}
}
@@ -579,6 +582,11 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID
return dto.CustomerPaymentReportItem{}, err
}
filterBy := strings.ToUpper(strings.TrimSpace(params.FilterBy))
if filterBy == "" {
filterBy = utils.CustomerPaymentFilterByTransDate
}
var startDate, endDate *time.Time
if params.StartDate != "" {
parsed, err := time.ParseInLocation("2006-01-02", params.StartDate, location)
@@ -597,11 +605,20 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID
}
for _, row := range rows {
transDate := row.TransDate.In(location)
if startDate != nil && transDate.Before(*startDate) {
var compareDate time.Time
if filterBy == utils.CustomerPaymentFilterByRealizationDate {
if row.DeliveryDate == nil {
continue
}
compareDate = row.DeliveryDate.In(location)
} else {
compareDate = row.TransDate.In(location)
}
if startDate != nil && compareDate.Before(*startDate) {
continue
}
if endDate != nil && transDate.After(*endDate) {
if endDate != nil && compareDate.After(*endDate) {
continue
}
filteredRows = append(filteredRows, row)
@@ -1352,10 +1369,12 @@ func buildDebtSupplierRow(purchase entity.Purchase, now time.Time, loc *time.Loc
poNumber = *purchase.PoNumber
}
prDate := purchase.CreatedAt.In(loc)
startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc)
startDate := resolveDebtSupplierReceivedDate(purchase, loc)
endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
aging := int(endDate.Sub(startDate).Hours() / 24)
aging := 0
if !startDate.IsZero() {
aging = int(endDate.Sub(startDate).Hours() / 24)
}
totalPrice := 0.0
travelNumber := "-"
@@ -1525,8 +1544,10 @@ func isDebtSupplierPaid(totalPrice, paymentTotal float64) bool {
}
func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc *time.Location) int {
prDate := purchase.CreatedAt.In(loc)
startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc)
startDate := resolveDebtSupplierReceivedDate(purchase, loc)
if startDate.IsZero() {
return 0
}
stopDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc)
if stopDate.Before(startDate) {
return 0
@@ -1534,6 +1555,23 @@ func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc
return int(stopDate.Sub(startDate).Hours() / 24)
}
func resolveDebtSupplierReceivedDate(purchase entity.Purchase, loc *time.Location) time.Time {
earliest := time.Time{}
for _, item := range purchase.Items {
if item.ReceivedDate == nil || item.ReceivedDate.IsZero() {
continue
}
received := item.ReceivedDate.In(loc)
if earliest.IsZero() || received.Before(earliest) {
earliest = received
}
}
if earliest.IsZero() {
return time.Time{}
}
return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc)
}
func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) {
params, filters, err := s.parseHppPerKandangQuery(ctx)
if err != nil {
@@ -85,6 +85,7 @@ type CustomerPaymentQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"`
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=TRANS_DATE REALIZATION_DATE"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
}
+13 -3
View File
@@ -161,6 +161,15 @@ const (
ExpenseCategoryNonBOP ExpenseCategory = "NON-BOP"
)
// -------------------------------------------------------------------
// Filter Customer Payment
// -------------------------------------------------------------------
const (
CustomerPaymentFilterByTransDate = "TRANS_DATE"
CustomerPaymentFilterByRealizationDate = "REALIZATION_DATE"
)
// -------------------------------------------------------------------
// Payment Method
// -------------------------------------------------------------------
@@ -354,9 +363,10 @@ const (
PurchaseStepReceiving approvalutils.ApprovalStep = 4
PurchaseStepCompleted approvalutils.ApprovalStep = 5
PurchasePRNumberPrefix = "PR-LTI-"
PurchasePONumberPrefix = "PO-LTI-"
PurchaseNumberPadding = 4
PurchasePRNumberPrefix = "PR-LTI-"
PurchasePONumberPrefix = "PO-LTI-"
AdjustmentStockNumberPrefix = "ADJ-"
PurchaseNumberPadding = 4
)
var PurchaseApprovalSteps = map[approvalutils.ApprovalStep]string{
+17 -3
View File
@@ -28,12 +28,26 @@ func MapDepletions(recordingID uint, items []validation.Depletion) []entity.Reco
return nil
}
result := make([]entity.RecordingDepletion, 0, len(items))
aggregate := make(map[uint]float64, len(items))
for _, item := range items {
if item.ProductWarehouseId == 0 || item.Qty == 0 {
continue
}
aggregate[item.ProductWarehouseId] += item.Qty
}
if len(aggregate) == 0 {
return nil
}
result := make([]entity.RecordingDepletion, 0, len(aggregate))
for warehouseID, qty := range aggregate {
if qty == 0 {
continue
}
result = append(result, entity.RecordingDepletion{
RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId,
Qty: item.Qty,
ProductWarehouseId: warehouseID,
Qty: qty,
})
}
return result