Compare commits

..

83 Commits

Author SHA1 Message Date
ragilap 2cf4ab03ad [FEAT/BE] down to hs256 without rotate key 2026-02-26 12:39:02 +07:00
Adnan Zahir 4d009978ae Merge branch 'fix/be/closing-unclosing' into 'development'
[FEAT/BE] fixing overhead,sapronak,perhitungan sapronak

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

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

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

See merge request mbugroup/lti-api!332
2026-02-23 06:44:10 +00:00
ragilap a3334c6bb0 [FEAT/BE] fixing approve status unclose 2026-02-23 11:56:53 +07:00
giovanni b73f13ee76 add query param filter has laying 2026-02-23 11:46:31 +07:00
ragilap 9d6a69dc4d [FEAT/BE] fix status closed project flock, closing perhitungan sapronak 2026-02-23 11:33:57 +07:00
ragilap 0ac174fdc6 [FEAT/BE] fix filter rejected delivery service 2026-02-22 21:12:57 +07:00
ragilap 4bf9b12680 [FEAT/BE] fix response closing and fix status rejected filter 2026-02-20 14:46:01 +07:00
ragilap 2028cee274 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/be/response-check-closing 2026-02-20 10:19:13 +07:00
ragilap 1c22c0f01c [FEAT/BE] fixing remaining stock check closing response 2026-02-20 10:19:00 +07:00
Adnan Zahir 5e28721651 Merge branch 'fix/stock' into 'development'
[FIX][BE]: fix insert stock to stocklog

See merge request mbugroup/lti-api!330
2026-02-20 09:51:52 +07:00
giovanni 95547ad7c7 fix insert stock to stocklog 2026-02-19 16:00:48 +07:00
Hafizh A. Y. 3fd96834f9 Merge branch 'fix/BE/fifo-recording-and-closing-perhitungan-sapronak' into 'development'
[FEAT/BE] add coloumn usage_qty and change standart ensure product

See merge request mbugroup/lti-api!329
2026-02-18 09:02:33 +00:00
ragilap 3da05eea02 [FEAT/BE] add coloumn usage_qty and change standart ensure product 2026-02-18 16:01:20 +07:00
Hafizh A. Y. 1e788e46f5 Merge branch 'fix/BE/fifo-recording-and-closing-perhitungan-sapronak' into 'development'
[FEAT/BE] add product flags in stock

See merge request mbugroup/lti-api!328
2026-02-18 08:38:45 +00:00
ragilap e0d42fe6d3 [FEAT/BE] add product flags in stock 2026-02-18 15:30:59 +07:00
Hafizh A. Y. 28f57525f4 Merge branch 'fix/be/fifo-recording-and-closing-perhitungan-sapronak' into 'development'
Fix/be/fifo recording and closing perhitungan sapronak

See merge request mbugroup/lti-api!327
2026-02-18 08:12:09 +00:00
ragilap 62496f78a8 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/be/fifo-recording-and-closing-perhitungan-sapronak 2026-02-18 11:49:38 +07:00
ragilap 36ba4f34bb [FEAT/BE] fixing fifo fallback recording,fixing backdate and fixing product category 2026-02-18 11:49:25 +07:00
Hafizh A. Y. 691573fbe5 Merge branch 'fix/closing-keuangan' into 'development'
[FIX][BE] Fix report closing keuangan duplicate ovk, and closing keuangan devided by last recording

See merge request mbugroup/lti-api!326
2026-02-18 04:37:36 +00:00
Hafizh A. Y 7f623c0c1f fix(BE): fix report closing keuangan duplicate ovk, and closing keuangan devided by last recording 2026-02-18 11:34:38 +07:00
Hafizh A. Y. ad93cbba7a Merge branch 'fix/BE/chickin-purchase-relation' into 'development'
Fix/be/change current stock usage product warehouse with pending stocks

See merge request mbugroup/lti-api!325
2026-02-17 08:32:17 +00:00
ragilap 756fc431b3 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/chickin-purchase-relation 2026-02-12 14:12:05 +07:00
ragilap 71ce855feb [FEAT/BE] change current stock with pending in product warehouse 2026-02-12 14:11:52 +07:00
Hafizh A. Y. 5705e39f53 Merge branch 'fix/BE/chickin-purchase-relation' into 'development'
[FEAT/BE] recording reject productwarehouse rolback

See merge request mbugroup/lti-api!324
2026-02-11 09:35:12 +00:00
ragilap 27c9c8cda7 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/chickin-purchase-relation 2026-02-11 10:10:21 +07:00
ragilap ad46f8aca0 [FEAT/BE] recording reject add 2026-02-11 09:57:51 +07:00
ragilap cad9328e5d [FEAT/BE] Add restrict purchase chickin 2026-02-10 15:21:48 +07:00
Hafizh A. Y. ac875a328e Merge branch 'feat/delivery-order-purchase-recordings' into 'development'
[FEAT/BE] Add saparator type search get all productwarehouse

See merge request mbugroup/lti-api!322
2026-02-10 03:47:04 +00:00
ragilap 8ad923a90a [FEAT/BE] Add saparator type search get all productwarehouse 2026-02-09 16:48:42 +07:00
Adnan Zahir b43979bbba Merge branch 'feat/delivery-order-purchase-recordings' into 'development'
Feat/delivery order purchase recordings

See merge request mbugroup/lti-api!315
2026-02-07 16:59:19 +07:00
M1 AIR 1a56b37e4e Create job for MR 2026-02-06 23:36:20 +07:00
ragilap 4340828fec [FEAT/BE] Add telur seeder and saparator category 2026-02-06 19:26:00 +07:00
ragilap f74b6476de [FEAT/BE] Add filter delivery order, adjust response purchase and fcr growing recording 2026-02-06 14:13:05 +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
ragilap 77ec805931 [FEAT/BE] Add filter delivery order and adjust response purchase 2026-02-06 01:02:03 +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
aguhh18 aa1fd1c35b FEAT[BE] :update price calculation in sales order service for accurate rounding, add new conversion unit for quantity 2026-02-05 09:57:38 +07: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
aguhh18 1d95976360 FEAT[BE] :add marketing type field to delivery and sales order DTOs, enhance validation and service logic for consistent marketing type handling 2026-02-04 14:47:56 +07: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
aguhh18 357b5709f5 FEAT[BE] :add conversion fields and week tracking to marketing product DTOs and update mapping functions 2026-02-04 12:48:05 +07:00
aguhh18 474c42770b FEAT[BE] :add week calculation and chickin preload to product warehouse services 2026-02-04 11:46:32 +07:00
aguhh18 90de167fcd FEAT[BE] :add type filtering and validation to product warehouse services 2026-02-04 09:59:15 +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
aguhh18 f59cdd821a FEAT[BE] :add marketing type and conversion fields to marketing entities and services 2026-02-03 13:32:37 +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
aguhh18 f75225b81b FEAT[BE] :add production standard detail creation for growing project category with zero target values 2026-02-03 12:02:45 +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
95 changed files with 5780 additions and 2493 deletions
+15 -1
View File
@@ -1,15 +1,29 @@
workflow: workflow:
rules: rules:
# MR pipeline
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: always
# Push pipeline hanya untuk env branch
- if: '$CI_COMMIT_BRANCH == "development"' - if: '$CI_COMMIT_BRANCH == "development"'
when: always
- if: '$CI_COMMIT_BRANCH == "staging"' - if: '$CI_COMMIT_BRANCH == "staging"'
when: always
- if: '$CI_COMMIT_BRANCH == "production"' - if: '$CI_COMMIT_BRANCH == "production"'
when: always
# Selain itu jangan buat pipeline
- when: never - when: never
include: include:
- local: "ci/development.yml" # khusus MR (notif)
- local: "ci/merge_request.yml"
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# khusus push ke branch env
- local: "ci/development.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "development"' - if: '$CI_COMMIT_BRANCH == "development"'
- local: "ci/staging.yml" - local: "ci/staging.yml"
+2
View File
@@ -42,6 +42,8 @@ Copy .env.example to .env and adjust the variables (e.g. DATABASE_URL, JWT secre
cp .env.example .env cp .env.example .env
``` ```
Catatan: isi `SSO_HS_SECRET` jika ingin verifikasi token HS256 tanpa JWKS.
### 5. Setup Docker ### 5. Setup Docker
Run initial docker. Run initial docker.
+6 -5
View File
@@ -4,9 +4,14 @@ stages:
deploy-dev: deploy-dev:
stage: deploy stage: deploy
image: alpine:3.20 image: alpine:3.20
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
when: on_success
- when: never
variables: variables:
DEPLOY_APP: "LTI-MBUGROUP" DEPLOY_APP: "LTI-MBUGROUP"
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
GIT_SUBMODULE_STRATEGY: recursive GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1" GIT_DEPTH: "1"
@@ -27,7 +32,6 @@ deploy-dev:
script: script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- > - >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e set -e
@@ -83,8 +87,5 @@ deploy-dev:
curl -sS -H "Content-Type: application/json" \ curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL"; -d @payload.json "$DISCORD_WEBHOOK_URL";
only:
- development
environment: environment:
name: development 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: tags:
- self-hosted-prod - self-hosted-prod
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
when: always
- when: never
variables: variables:
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
@@ -30,7 +24,9 @@ variables:
build_production: build_production:
stage: build stage: build
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
script: | script: |
set -e set -e
docker info docker info
@@ -47,14 +43,15 @@ build_production:
docker tag "$IMAGE_NAME" "$IMAGE_LATEST" docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST" docker push "$IMAGE_LATEST"
# ========================= # =========================
# MIGRATE (PRODUCTION) # MIGRATE (PRODUCTION)
# ========================= # =========================
migrate_production: migrate_production:
stage: migrate stage: migrate
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs: needs:
- job: build_production - job: build_production
artifacts: false artifacts: false
@@ -66,12 +63,10 @@ migrate_production:
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) 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) test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
# ✅ load env dari server
set -a set -a
. ./.env . ./.env
set +a set +a
# ✅ validasi
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER 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}" export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL" echo "✅ DATABASE_URL=$DATABASE_URL"
# ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) # NOTE: pastikan nama servicenya benar untuk production (ini sebelumnya masih stg-*)
echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true 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 ':')" 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)" 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) 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..." echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations" ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
@@ -111,7 +98,6 @@ migrate_production:
echo "$out" echo "$out"
# ✅ Handle no change dengan benar (tidak false-success)
if echo "$out" | grep -qi "no change"; then if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)" echo "✅ No change (already up to date)"
exit 0 exit 0
@@ -124,17 +110,16 @@ migrate_production:
echo "✅ Migration applied successfully" echo "✅ Migration applied successfully"
# ========================= # =========================
# DEPLOY (AUTO) # DEPLOY (AUTO)
# ========================= # =========================
deploy_production: deploy_production:
stage: deploy stage: deploy
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs: needs:
# - job: migrate_production
# artifacts: false
- job: build_production - job: build_production
artifacts: false artifacts: false
script: | script: |
@@ -150,7 +135,6 @@ deploy_production:
docker compose -f "$COMPOSE_FILE" up -d --force-recreate docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f docker image prune -f
# ========================= # =========================
# SEED (MANUAL) # SEED (MANUAL)
# ========================= # =========================
@@ -159,9 +143,10 @@ seed_production:
rules: rules:
- if: '$CI_COMMIT_BRANCH == "production"' - if: '$CI_COMMIT_BRANCH == "production"'
when: manual when: manual
- when: never
script: | script: |
set -e set -e
cd /opt/deploy/lti cd "$DEPLOY_DIR"
test -f .env || (echo "❌ .env not found" && exit 1) test -f .env || (echo "❌ .env not found" && exit 1)
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
+13 -22
View File
@@ -8,12 +8,6 @@ default:
tags: tags:
- self-hosted-stg - self-hosted-stg
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
when: always
- when: never
variables: variables:
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
@@ -30,7 +24,9 @@ variables:
build_staging: build_staging:
stage: build stage: build
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
script: | script: |
set -e set -e
docker info docker info
@@ -47,14 +43,15 @@ build_staging:
docker tag "$IMAGE_NAME" "$IMAGE_LATEST" docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST" docker push "$IMAGE_LATEST"
# ========================= # =========================
# MIGRATE (AUTO) # MIGRATE (AUTO)
# ========================= # =========================
migrate_staging: migrate_staging:
stage: migrate stage: migrate
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
needs: needs:
- job: build_staging - job: build_staging
artifacts: false artifacts: false
@@ -66,12 +63,10 @@ migrate_staging:
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) 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) test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
# ✅ load env dari server
set -a set -a
. ./.env . ./.env
set +a set +a
# ✅ validasi
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER 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}" export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL" echo "✅ DATABASE_URL=$DATABASE_URL"
# ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!)
echo "✅ Ensuring postgres & redis running ..." echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true 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 ':')" COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" 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)" 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) test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Docker network detected: $NETWORK_NAME" echo "✅ Docker network detected: $NETWORK_NAME"
# ✅ Migrations dari repo (CI workspace)
echo "✅ Checking migrations from repo..." echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations" ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
@@ -111,7 +102,6 @@ migrate_staging:
echo "$out" echo "$out"
# ✅ Handle no change dengan benar (tidak false-success)
if echo "$out" | grep -qi "no change"; then if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)" echo "✅ No change (already up to date)"
exit 0 exit 0
@@ -124,14 +114,15 @@ migrate_staging:
echo "✅ Migration applied successfully" echo "✅ Migration applied successfully"
# ========================= # =========================
# DEPLOY (AUTO) # DEPLOY (AUTO)
# ========================= # =========================
deploy_staging: deploy_staging:
stage: deploy stage: deploy
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
needs: needs:
- job: migrate_staging - job: migrate_staging
artifacts: false artifacts: false
@@ -150,18 +141,18 @@ deploy_staging:
docker compose -f "$COMPOSE_FILE" up -d --force-recreate docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f docker image prune -f
# ========================= # =========================
# SEED (MANUAL) # SEED (MANUAL)
# ========================= # =========================
seed_staging: seed_staging:
stage: seed stage: seed
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - if: '$CI_COMMIT_BRANCH == "staging"'
when: manual
- when: never
needs: needs:
- job: deploy_staging - job: deploy_staging
artifacts: false artifacts: false
when: manual
allow_failure: false allow_failure: false
script: | script: |
set -e set -e
@@ -170,4 +161,4 @@ seed_staging:
test -f .env || (echo "❌ .env not found" && exit 1) test -f .env || (echo "❌ .env not found" && exit 1)
docker compose -f "$COMPOSE_FILE" pull seed || true docker compose -f "$COMPOSE_FILE" pull seed || true
docker compose -f "$COMPOSE_FILE" run --rm seed% docker compose -f "$COMPOSE_FILE" run --rm seed
+1 -1
View File
@@ -69,7 +69,7 @@ func setupSSO(ctx context.Context, rdb *redis.Client) {
var lastErr error var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ { for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := sso.Init(ctx, config.SSOJWKSURL, config.SSOIssuer, config.SSOAllowedAudiences); err != nil { if err := sso.Init(ctx, config.SSOJWKSURL, config.SSOIssuer, config.SSOAllowedAudiences, config.SSOHMACSecret); err != nil {
lastErr = err lastErr = err
utils.Log.WithError(err).Warnf("SSO initialization attempt %d/%d failed", attempt, maxAttempts) utils.Log.WithError(err).Warnf("SSO initialization attempt %d/%d failed", attempt, maxAttempts)
select { select {
@@ -139,12 +139,11 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). 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 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 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("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
Where("f.name IN ?", flags). Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
+95 -20
View File
@@ -26,6 +26,7 @@ type FifoService interface {
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error
ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error)
} }
type fifoService struct { type fifoService struct {
@@ -111,6 +112,11 @@ type PendingResolution struct {
Quantity float64 Quantity float64
} }
type PendingResolveRequest struct {
ProductWarehouseID uint
Tx *gorm.DB
}
type StockReplenishResult struct { type StockReplenishResult struct {
AddedQuantity float64 AddedQuantity float64
PendingResolved []PendingResolution PendingResolved []PendingResolution
@@ -147,6 +153,7 @@ type StockReleaseRequest struct {
Reason *string Reason *string
Tx *gorm.DB Tx *gorm.DB
} }
func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error { func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return errors.New("stockable key and id are required") return errors.New("stockable key and id are required")
@@ -226,6 +233,23 @@ func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest)
return result, nil return result, nil
} }
func (s *fifoService) ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error) {
if req.ProductWarehouseID == 0 {
return nil, errors.New("product warehouse id is required")
}
var resolved []PendingResolution
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
var err error
resolved, err = s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
return err
})
if err != nil {
return nil, err
}
return resolved, nil
}
func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) { func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) {
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return nil, errors.New("usable key and id are required") return nil, errors.New("usable key and id are required")
@@ -308,7 +332,7 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
} }
if reductionTarget > 0 { 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 { if err != nil {
return err return err
} }
@@ -355,7 +379,7 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
} }
var usageDelta, pendingDelta float64 var usageDelta, pendingDelta float64
if ctxRow.UsageQty > 0 { 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 return err
} }
usageDelta -= ctxRow.UsageQty usageDelta -= ctxRow.UsageQty
@@ -721,6 +745,7 @@ func (s *fifoService) releaseUsagePortion(
usableKey fifo.UsableKey, usableKey fifo.UsableKey,
usableID uint, usableID uint,
target float64, target float64,
expectedWarehouseID uint,
) (float64, error) { ) (float64, error) {
if target <= 0 { if target <= 0 {
return 0, nil return 0, nil
@@ -736,6 +761,18 @@ func (s *fifoService) releaseUsagePortion(
if len(allocations) == 0 { if len(allocations) == 0 {
return 0, nil 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 ( var (
remaining = target remaining = target
@@ -832,30 +869,30 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
cfg.Columns.CreatedAt, cfg.Columns.CreatedAt,
) )
var rows []struct { if cfg.Columns.CreatedAt == cfg.Columns.ID {
ID uint var rows []struct {
Pending float64 ID uint
CreatedAt time.Time Pending float64 `gorm:"column:pending_qty"`
} CreatedAt int64 `gorm:"column:created_at"`
}
query := tx.Table(cfg.Table). query := tx.Table(cfg.Table).
Select(selectStmt). Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)). Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable) Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil { if cfg.Scope != nil {
query = cfg.Scope(query) query = cfg.Scope(query)
} }
for _, order := range s.orderClauses(cfg.OrderBy) { for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order) query = query.Order(order)
} }
if err := query.Find(&rows).Error; err != nil { if err := query.Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
for _, row := range rows { for _, row := range rows {
if row.Pending <= 0 { if row.Pending <= 0 {
continue continue
@@ -865,9 +902,47 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
Config: cfg, Config: cfg,
UsableID: row.ID, UsableID: row.ID,
Pending: row.Pending, Pending: row.Pending,
CreatedAt: row.CreatedAt, CreatedAt: time.Unix(0, row.CreatedAt),
}) })
} }
} else {
var rows []struct {
ID uint
Pending float64 `gorm:"column:pending_qty"`
CreatedAt time.Time `gorm:"column:created_at"`
}
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,
})
}
}
} }
if len(candidates) == 0 { if len(candidates) == 0 {
+7
View File
@@ -50,6 +50,7 @@ var (
CORSMaxAge int CORSMaxAge int
SSOIssuer string SSOIssuer string
SSOJWKSURL string SSOJWKSURL string
SSOHMACSecret string
SSOAllowedAudiences []string SSOAllowedAudiences []string
SSOAuthorizeURL string SSOAuthorizeURL string
SSOTokenURL string SSOTokenURL string
@@ -57,6 +58,7 @@ var (
SSOPortalURL string SSOPortalURL string
SSOClients map[string]SSOClientConfig SSOClients map[string]SSOClientConfig
SSOAccessCookieName string SSOAccessCookieName string
SSOAccessCookieFallback []string
SSORefreshCookieName string SSORefreshCookieName string
SSOCookieDomain string SSOCookieDomain string
SSOCookieSecure bool SSOCookieSecure bool
@@ -135,12 +137,14 @@ func init() {
// SSO integration // SSO integration
SSOIssuer = viper.GetString("SSO_ISSUER") SSOIssuer = viper.GetString("SSO_ISSUER")
SSOJWKSURL = viper.GetString("SSO_JWKS_URL") SSOJWKSURL = viper.GetString("SSO_JWKS_URL")
SSOHMACSecret = viper.GetString("SSO_HS_SECRET")
SSOAllowedAudiences = parseList("SSO_ALLOWED_AUDIENCES") SSOAllowedAudiences = parseList("SSO_ALLOWED_AUDIENCES")
SSOAuthorizeURL = viper.GetString("SSO_AUTHORIZE_URL") SSOAuthorizeURL = viper.GetString("SSO_AUTHORIZE_URL")
SSOTokenURL = viper.GetString("SSO_TOKEN_URL") SSOTokenURL = viper.GetString("SSO_TOKEN_URL")
SSOGetMeURL = viper.GetString("SSO_GETME_URL") SSOGetMeURL = viper.GetString("SSO_GETME_URL")
SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL")) SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL"))
SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access") SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access")
SSOAccessCookieFallback = parseList("SSO_ACCESS_COOKIE_FALLBACK")
SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh") SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh")
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE") SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE")
@@ -268,6 +272,9 @@ func ensureProdConfig() {
if SSOAuthorizeURL == "" || !strings.HasPrefix(SSOAuthorizeURL, "https://") { if SSOAuthorizeURL == "" || !strings.HasPrefix(SSOAuthorizeURL, "https://") {
panic("SSO_AUTHORIZE_URL must be https in production") panic("SSO_AUTHORIZE_URL must be https in production")
} }
if strings.TrimSpace(SSOHMACSecret) == "" && strings.TrimSpace(SSOJWKSURL) == "" {
panic("SSO_JWKS_URL or SSO_HS_SECRET must be configured in production")
}
if SSOTokenURL == "" || !strings.HasPrefix(SSOTokenURL, "https://") { if SSOTokenURL == "" || !strings.HasPrefix(SSOTokenURL, "https://") {
panic("SSO_TOKEN_URL must be https in production") panic("SSO_TOKEN_URL must be https in production")
} }
@@ -1,5 +1,5 @@
-- Create sequence for transfer laying movement number -- Create sequence for transfer laying movement number
CREATE SEQUENCE transfer_laying_seq START CREATE SEQUENCE IF NOT EXISTS transfer_laying_seq START
WITH WITH
1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 NO CYCLE; 1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 NO CYCLE;
@@ -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,8 @@
-- Remove columns from marketing_products
ALTER TABLE marketing_products
DROP COLUMN IF EXISTS week,
DROP COLUMN IF EXISTS weight_per_convertion,
DROP COLUMN IF EXISTS convertion_unit;
-- Remove column from marketings
ALTER TABLE marketings DROP COLUMN IF EXISTS marketing_type;
@@ -0,0 +1,9 @@
-- Add marketing_type to marketings table
ALTER TABLE marketings
ADD COLUMN IF NOT EXISTS marketing_type VARCHAR(50);
-- Add convertion fields to marketing_products table
ALTER TABLE marketing_products
ADD COLUMN IF NOT EXISTS convertion_unit VARCHAR(20),
ADD COLUMN IF NOT EXISTS weight_per_convertion NUMERIC(15, 3),
ADD COLUMN IF NOT EXISTS week INTEGER;
@@ -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;
@@ -0,0 +1,12 @@
BEGIN;
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS chk_recording_depletions_pending_zero;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS total_used_qty;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS usage_qty;
COMMIT;
@@ -0,0 +1,15 @@
BEGIN;
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS usage_qty numeric(15, 3) NOT NULL DEFAULT 0;
UPDATE recording_depletions
SET pending_qty = 0
WHERE pending_qty IS NULL OR pending_qty <> 0;
ALTER TABLE recording_depletions
ADD CONSTRAINT chk_recording_depletions_pending_zero
CHECK (pending_qty = 0);
COMMIT;
+25 -5
View File
@@ -74,7 +74,7 @@ func seedUsers(tx *gorm.DB) (map[string]uint, error) {
} }
func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) { func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) {
names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor"} names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor", "Butir"}
result := make(map[string]uint, len(names)) result := make(map[string]uint, len(names))
for _, name := range names { for _, name := range names {
@@ -235,7 +235,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Utuh", Name: "Telur Utuh",
Brand: "-", Brand: "-",
Sku: "4", Sku: "4",
Uom: "Gram", Uom: "Butir",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurUtuh}, Flags: []utils.FlagType{utils.FlagTelurUtuh},
@@ -245,7 +245,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Pecah", Name: "Telur Pecah",
Brand: "-", Brand: "-",
Sku: "5", Sku: "5",
Uom: "Gram", Uom: "Butir",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPecah}, Flags: []utils.FlagType{utils.FlagTelurPecah},
@@ -255,7 +255,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Putih", Name: "Telur Putih",
Brand: "-", Brand: "-",
Sku: "6", Sku: "6",
Uom: "Gram", Uom: "Butir",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPutih}, Flags: []utils.FlagType{utils.FlagTelurPutih},
@@ -265,12 +265,32 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Retak", Name: "Telur Retak",
Brand: "-", Brand: "-",
Sku: "7", Sku: "7",
Uom: "Gram", Uom: "Butir",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurRetak}, Flags: []utils.FlagType{utils.FlagTelurRetak},
IsVisible: false, IsVisible: false,
}, },
{
Name: "Telur Papacal",
Brand: "-",
Sku: "8",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelur},
IsVisible: false,
},
{
Name: "Telur Jumbo",
Brand: "-",
Sku: "9",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelur},
IsVisible: false,
},
} }
for _, seed := range seeds { for _, seed := range seeds {
+1
View File
@@ -11,6 +11,7 @@ type AdjustmentStock struct {
PendingQty float64 `gorm:"column:pending_qty;default:0"` PendingQty float64 `gorm:"column:pending_qty;default:0"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"` StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
+1
View File
@@ -14,6 +14,7 @@ type Marketing struct {
SoDate time.Time `gorm:"type:date;not null"` SoDate time.Time `gorm:"type:date;not null"`
SalesPersonId uint `gorm:"not null"` SalesPersonId uint `gorm:"not null"`
Notes string `gorm:"type:text"` Notes string `gorm:"type:text"`
MarketingType string `gorm:"type:varchar(50)"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
+11 -8
View File
@@ -1,14 +1,17 @@
package entities package entities
type MarketingProduct struct { type MarketingProduct struct {
Id uint `gorm:"primaryKey;autoIncrement"` Id uint `gorm:"primaryKey;autoIncrement"`
MarketingId uint `gorm:"not null"` MarketingId uint `gorm:"not null"`
ProductWarehouseId uint `gorm:"not null"` ProductWarehouseId uint `gorm:"not null"`
Qty float64 `gorm:"type:numeric(15,3);not null"` Qty float64 `gorm:"type:numeric(15,3);not null"`
UnitPrice float64 `gorm:"type:numeric(15,3);not null"` ConvertionUnit *string `gorm:"type:varchar(20)"`
AvgWeight float64 `gorm:"type:numeric(15,3);not null"` WeightPerConvertion *float64 `gorm:"type:numeric(15,3)"`
TotalWeight float64 `gorm:"type:numeric(15,3);not null"` Week *int `gorm:"type:integer"`
TotalPrice float64 `gorm:"type:numeric(15,3);not null"` UnitPrice float64 `gorm:"type:numeric(15,3);not null"`
AvgWeight float64 `gorm:"type:numeric(15,3);not null"`
TotalWeight float64 `gorm:"type:numeric(15,3);not null"`
TotalPrice float64 `gorm:"type:numeric(15,3);not null"`
Marketing Marketing `gorm:"foreignKey:MarketingId;references:Id"` Marketing Marketing `gorm:"foreignKey:MarketingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
-2
View File
@@ -11,7 +11,6 @@ type ProjectFlock struct {
FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"` FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"`
AreaId uint `gorm:"not null"` AreaId uint `gorm:"not null"`
Category string `gorm:"type:varchar(20);not null"` Category string `gorm:"type:varchar(20);not null"`
FcrId uint `gorm:"not null"`
ProductionStandardId uint `gorm:"column:production_standard_id"` ProductionStandardId uint `gorm:"column:production_standard_id"`
LocationId uint `gorm:"not null"` LocationId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
@@ -20,7 +19,6 @@ type ProjectFlock struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Area Area `gorm:"foreignKey:AreaId;references:Id"` Area Area `gorm:"foreignKey:AreaId;references:Id"`
Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"`
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
+1
View File
@@ -21,6 +21,7 @@ type PurchaseItem struct {
Price float64 `gorm:"type:numeric(15,3);default:0"` Price float64 `gorm:"type:numeric(15,3);default:0"`
TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` TotalPrice float64 `gorm:"type:numeric(15,3);default:0"`
ExpenseNonstockId *uint64 ExpenseNonstockId *uint64
HasChickin bool `gorm:"-" json:"-"`
// Relations // Relations
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
+2
View File
@@ -12,7 +12,9 @@ type Recording struct {
RecordDatetime time.Time `gorm:"column:record_datetime;not null"` RecordDatetime time.Time `gorm:"column:record_datetime;not null"`
Day *int `gorm:"column:day"` Day *int `gorm:"column:day"`
TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"` TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"`
TotalDepletionCumQty *float64 `gorm:"-"`
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
DepletionRate *float64 `gorm:"-"`
CumIntake *int `gorm:"column:cum_intake"` CumIntake *int `gorm:"column:cum_intake"`
FcrValue *float64 `gorm:"column:fcr_value"` FcrValue *float64 `gorm:"column:fcr_value"`
TotalChickQty *float64 `gorm:"column:total_chick_qty"` TotalChickQty *float64 `gorm:"column:total_chick_qty"`
+1
View File
@@ -6,6 +6,7 @@ type RecordingDepletion struct {
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
Qty float64 `gorm:"column:qty;not null"` Qty float64 `gorm:"column:qty;not null"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"` PendingQty float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
+59 -13
View File
@@ -19,11 +19,11 @@ const (
// AuthContext keeps authentication details captured by the middleware. // AuthContext keeps authentication details captured by the middleware.
type AuthContext struct { type AuthContext struct {
Token string Token string
Verification *sso.VerificationResult Verification *sso.VerificationResult
User *entity.User User *entity.User
Roles []sso.Role Roles []sso.Role
Permissions map[string]struct{} Permissions map[string]struct{}
UserAreaIDs []uint UserAreaIDs []uint
UserLocationIDs []uint UserLocationIDs []uint
UserAllArea bool UserAllArea bool
@@ -36,8 +36,30 @@ type AuthContext struct {
func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
token := bearerToken(c) token := bearerToken(c)
if token == "" { tokenSource := ""
token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) if token != "" {
tokenSource = "header"
} else {
primaryName := strings.TrimSpace(config.SSOAccessCookieName)
if primaryName != "" {
token = strings.TrimSpace(c.Cookies(primaryName))
if token != "" {
tokenSource = "cookie:" + primaryName
}
}
if token == "" {
for _, name := range config.SSOAccessCookieFallback {
name = strings.TrimSpace(name)
if name == "" || name == primaryName {
continue
}
token = strings.TrimSpace(c.Cookies(name))
if token != "" {
tokenSource = "cookie:" + name
break
}
}
}
} }
if token == "" { if token == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
@@ -45,7 +67,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
verification, err := sso.VerifyAccessToken(token) verification, err := sso.VerifyAccessToken(token)
if err != nil { if err != nil {
utils.Log.WithError(err).Warn("auth: token verification failed") if sso.IsSignatureError(err) {
logSignatureError("auth", tokenSource, token, err)
} else {
utils.Log.WithError(err).Warn("auth: token verification failed")
}
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
} }
@@ -89,11 +115,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
} }
ctx := &AuthContext{ ctx := &AuthContext{
Token: token, Token: token,
Verification: verification, Verification: verification,
User: user, User: user,
Roles: roles, Roles: roles,
Permissions: permissions, Permissions: permissions,
UserAreaIDs: nil, UserAreaIDs: nil,
UserLocationIDs: nil, UserLocationIDs: nil,
UserAllArea: false, UserAllArea: false,
@@ -216,6 +242,26 @@ func hasAllScopes(have, required []string) bool {
return true return true
} }
func logSignatureError(ctxLabel, tokenSource, token string, err error) {
info := sso.ExtractTokenInfo(token)
aud := strings.Join(info.Aud, ",")
utils.Log.Errorf(
"access token verification failed: %v | ctx=%s source=%s iss=%s kid=%s aud=%s sub=%s exp=%d iat=%d nbf=%d expected_iss=%s expected_aud=%v",
err,
ctxLabel,
tokenSource,
info.Iss,
info.Kid,
aud,
info.Sub,
info.Exp,
info.Iat,
info.Nbf,
config.SSOIssuer,
config.SSOAllowedAudiences,
)
}
// RequirePermissions ensures the authenticated user possesses all specified permissions. // RequirePermissions ensures the authenticated user possesses all specified permissions.
func RequirePermissions(perms ...string) fiber.Handler { func RequirePermissions(perms ...string) fiber.Handler {
required := canonicalPermissions(perms) required := canonicalPermissions(perms)
@@ -347,12 +347,12 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
} }
result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag) result, productFlags, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
if err != nil { if err != nil {
return err return err
} }
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag) payload := dto.ToSapronakProjectAggregatedFromReports(result, flag, productFlags)
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{ JSON(response.Success{
@@ -377,12 +377,12 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
} }
result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag) result, productFlags, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
if err != nil { if err != nil {
return err return err
} }
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag) payload := dto.ToSapronakProjectAggregatedFromReport(result, flag, productFlags)
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{ JSON(response.Success{
@@ -1,8 +1,6 @@
package dto package dto
import ( import (
"encoding/json"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
) )
@@ -71,7 +69,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal
return dto return dto
} }
func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int, projectFlockKandangCountMap map[uint]int) OverheadListDTO { func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int) OverheadListDTO {
overheadsByNonstockID := make(map[uint]*OverheadDTO) overheadsByNonstockID := make(map[uint]*OverheadDTO)
latestDateByNonstockID := make(map[uint]string) latestDateByNonstockID := make(map[uint]string)
@@ -113,35 +111,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex
qty := realizations[i].Qty qty := realizations[i].Qty
totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price) totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price)
// Farm-level expense division
if realizations[i].ExpenseNonstock.Expense != nil &&
realizations[i].ExpenseNonstock.Expense.ProjectFlockId != nil {
projectFlockIDs := parseProjectFlockIDsFromJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId)
if len(projectFlockIDs) > 0 {
totalKandangInAllProjects := 0
for _, pfID := range projectFlockIDs {
if count, exists := projectFlockKandangCountMap[pfID]; exists {
totalKandangInAllProjects += count
}
}
if totalKandangInAllProjects > 0 {
if isPerKandang {
qty = qty / float64(totalKandangInAllProjects)
totalAmount = totalAmount / float64(totalKandangInAllProjects)
} else {
// Overhead ALL: divide by total kandang then multiply by this project's kandang count
perKandangAmount := totalAmount / float64(totalKandangInAllProjects)
perKandangQty := qty / float64(totalKandangInAllProjects)
qty = perKandangQty * float64(totalKandangCount)
totalAmount = perKandangAmount * float64(totalKandangCount)
}
}
}
}
overheadsByNonstockID[nonstockID].ActualQuantity += qty overheadsByNonstockID[nonstockID].ActualQuantity += qty
overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount
@@ -191,27 +160,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex
} }
} }
func parseProjectFlockIDsFromJSON(projectFlockJSON string) []uint {
if projectFlockJSON == "" {
return []uint{}
}
var projectFlocks []uint
if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil {
return []uint{}
}
return projectFlocks
}
func countProjectFlocksInJSON(projectFlockJSON string) int {
projectFlocks := parseProjectFlockIDsFromJSON(projectFlockJSON)
if len(projectFlocks) == 0 {
return 1
}
return len(projectFlocks)
}
func getItemInfo(nonstock *entity.Nonstock) (string, string) { func getItemInfo(nonstock *entity.Nonstock) (string, string) {
if nonstock != nil && nonstock.Id != 0 { if nonstock != nil && nonstock.Id != 0 {
return nonstock.Name, nonstock.Uom.Name return nonstock.Name, nonstock.Uom.Name
@@ -1,6 +1,7 @@
package dto package dto
import ( import (
"sort"
"strings" "strings"
"time" "time"
) )
@@ -127,7 +128,7 @@ type UomSummaryDTO struct {
// === Mapper Functions for Aggregated Sapronak Response === // === Mapper Functions for Aggregated Sapronak Response ===
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{} result := SapronakProjectAggregatedDTO{}
if len(reports) == 0 { if len(reports) == 0 {
@@ -135,10 +136,10 @@ func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag st
} }
rep := reports[0] rep := reports[0]
return ToSapronakProjectAggregatedFromReport(&rep, flag) return ToSapronakProjectAggregatedFromReport(&rep, flag, productFlags)
} }
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{} result := SapronakProjectAggregatedDTO{}
if report == nil { if report == nil {
@@ -175,6 +176,53 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
return t.Format("02-Jan-2006") return t.Format("02-Jan-2006")
} }
flagOrder := map[string]int{
"DOC": 0,
"PAKAN": 0,
"OVK": 0,
"PULLET": 0,
}
buildFlagList := func(productID uint, fallback string) string {
rawFlags := productFlags[productID]
if len(rawFlags) == 0 {
if fallback == "" {
return ""
}
return fallback
}
seen := make(map[string]struct{}, len(rawFlags))
ordered := make([]string, 0, len(rawFlags))
for _, f := range rawFlags {
flagName := strings.ToUpper(strings.TrimSpace(f))
if flagName == "" {
continue
}
if _, ok := seen[flagName]; ok {
continue
}
seen[flagName] = struct{}{}
ordered = append(ordered, flagName)
}
sort.SliceStable(ordered, func(i, j int) bool {
li := ordered[i]
lj := ordered[j]
ri, iok := flagOrder[li]
rj, jok := flagOrder[lj]
if iok != jok {
if iok {
return true
}
return false
}
if iok && jok && ri != rj {
return ri < rj
}
return li < lj
})
return strings.Join(ordered, " ")
}
for _, group := range report.Groups { for _, group := range report.Groups {
flagKey := normalizeFlag(group.Flag) flagKey := normalizeFlag(group.Flag)
ptr := byFlag[flagKey] ptr := byFlag[flagKey]
@@ -206,7 +254,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
Date: formatDate(item.Tanggal), Date: formatDate(item.Tanggal),
ReferenceNumber: item.NoReferensi, ReferenceNumber: item.NoReferensi,
Description: item.ProductName, Description: item.ProductName,
ProductCategory: item.ProductName, ProductCategory: buildFlagList(item.ProductID, flagKey),
UnitPrice: item.Harga, UnitPrice: item.Harga,
Notes: "-", Notes: "-",
} }
@@ -234,14 +282,14 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
row.Notes = "TRANSFER STOCK" row.Notes = "TRANSFER STOCK"
} }
} }
case "pemakaian", "adjustment keluar": case "pemakaian":
price := row.UnitPrice price := row.UnitPrice
if price == 0 { if price == 0 {
price = item.Harga price = item.Harga
} }
row.QtyUsed += item.QtyKeluar row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price row.TotalAmount += item.QtyKeluar * price
case "mutasi keluar", "penjualan": case "adjustment keluar", "mutasi keluar", "penjualan":
price := row.UnitPrice price := row.UnitPrice
if price == 0 { if price == 0 {
price = item.Harga price = item.Harga
@@ -269,6 +317,27 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
} }
// For chicken categories, keep qty_used aligned with qty_in - qty_out.
// Sales are excluded; usage represents remaining after transfers.
adjustChicken := func(cat *SapronakCategoryDTO) {
if cat == nil {
return
}
for i := range cat.Rows {
row := &cat.Rows[i]
remaining := row.QtyIn - row.QtyOut
if remaining < 0 {
remaining = 0
}
row.QtyUsed = remaining
if row.UnitPrice > 0 {
row.TotalAmount = row.QtyUsed * row.UnitPrice
}
}
}
adjustChicken(result.Doc)
adjustChicken(result.Pullet)
buildTotals := func(cat *SapronakCategoryDTO, label string) { buildTotals := func(cat *SapronakCategoryDTO, label string) {
if cat == nil { if cat == nil {
return return
@@ -297,5 +366,22 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
buildTotals(result.Doc, "TOTAL DOC") buildTotals(result.Doc, "TOTAL DOC")
buildTotals(result.Ovk, "TOTAL OVK") buildTotals(result.Ovk, "TOTAL OVK")
buildTotals(result.Pakan, "TOTAL PAKAN") buildTotals(result.Pakan, "TOTAL PAKAN")
// For chicken categories, enforce total qty_used = qty_in - qty_out.
adjustChickenTotal := func(cat *SapronakCategoryDTO) {
if cat == nil {
return
}
remaining := cat.Total.QtyIn - cat.Total.QtyOut
if remaining < 0 {
remaining = 0
}
cat.Total.QtyUsed = remaining
if cat.Total.AvgUnitPrice > 0 {
cat.Total.TotalAmount = cat.Total.AvgUnitPrice * remaining
}
}
adjustChickenTotal(result.Doc)
adjustChickenTotal(result.Pullet)
return result return result
} }
@@ -24,18 +24,18 @@ type ClosingRepository interface {
SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error)
SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error)
SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error)
GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error)
GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error)
FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error)
FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error)
FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error)
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error)
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
} }
@@ -86,6 +86,8 @@ type SapronakQueryParams struct {
Limit int Limit int
Offset int Offset int
Search string Search string
StartDate *time.Time
EndDate *time.Time
} }
func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) {
@@ -101,12 +103,12 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
if len(params.WarehouseIDs) == 0 { if len(params.WarehouseIDs) == 0 {
return []SapronakRow{}, 0, nil return []SapronakRow{}, 0, nil
} }
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs) args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing: case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 { if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL) unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
args = append(args, params.WarehouseIDs) args = append(args, params.WarehouseIDs, params.WarehouseIDs)
} }
if len(params.ProjectFlockKandangIDs) > 0 { if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
@@ -142,15 +144,33 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
} }
var totalResults int64 var totalResults int64
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause) dateClause := ""
countArgs := append(append([]any{}, args...), searchArgs...) var dateArgs []any
if params.StartDate != nil {
dateClause += " AND sort_date::date >= ?"
dateArgs = append(dateArgs, params.StartDate)
}
if params.EndDate != nil {
dateClause += " AND sort_date::date <= ?"
dateArgs = append(dateArgs, params.EndDate)
}
whereClause := searchClause
if dateClause != "" {
if whereClause == "" {
whereClause = " WHERE " + strings.TrimPrefix(dateClause, " AND ")
} else {
whereClause += dateClause
}
}
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, whereClause)
countArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...)
if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil { if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
dataArgs := append(append([]any{}, args...), searchArgs...) dataArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...)
dataArgs = append(dataArgs, params.Limit, params.Offset) dataArgs = append(dataArgs, params.Limit, params.Offset)
dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause) dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, whereClause)
var rows []SapronakRow var rows []SapronakRow
if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil {
@@ -173,12 +193,12 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S
if len(params.WarehouseIDs) == 0 { if len(params.WarehouseIDs) == 0 {
return []SapronakSummaryRow{}, nil return []SapronakSummaryRow{}, nil
} }
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs) args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing: case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 { if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL) unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
args = append(args, params.WarehouseIDs) args = append(args, params.WarehouseIDs, params.WarehouseIDs)
} }
if len(params.ProjectFlockKandangIDs) > 0 { if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
@@ -213,6 +233,25 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S
searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like) searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like)
} }
dateClause := ""
var dateArgs []any
if params.StartDate != nil {
dateClause += " AND sort_date::date >= ?"
dateArgs = append(dateArgs, params.StartDate)
}
if params.EndDate != nil {
dateClause += " AND sort_date::date <= ?"
dateArgs = append(dateArgs, params.EndDate)
}
whereClause := searchClause
if dateClause != "" {
if whereClause == "" {
whereClause = " WHERE " + strings.TrimPrefix(dateClause, " AND ")
} else {
whereClause += dateClause
}
}
querySQL := fmt.Sprintf(` querySQL := fmt.Sprintf(`
SELECT SELECT
product_category AS category, product_category AS category,
@@ -222,8 +261,8 @@ SELECT
FROM (%s) AS combined%s FROM (%s) AS combined%s
GROUP BY product_category, unit_id, unit GROUP BY product_category, unit_id, unit
ORDER BY product_category ASC, unit ASC ORDER BY product_category ASC, unit ASC
`, unionSQL, searchClause) `, unionSQL, whereClause)
queryArgs := append(append([]any{}, args...), searchArgs...) queryArgs := append(append(append([]any{}, args...), searchArgs...), dateArgs...)
var rows []SapronakSummaryRow var rows []SapronakSummaryRow
if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil { if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil {
@@ -392,22 +431,6 @@ func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFla
return agg.TotalQty, nil 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) { func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) {
db := r.DB().WithContext(ctx) db := r.DB().WithContext(ctx)
@@ -455,7 +478,7 @@ SELECT
COALESCE(pi.received_date, '1970-01-01') AS sort_date, COALESCE(pi.received_date, '1970-01-01') AS sort_date,
COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text, COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(p.po_number, '') AS reference_number, COALESCE(p.po_number, '') AS reference_number,
'Purchase' AS transaction_type, 'Pembelian' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -504,7 +527,7 @@ SELECT
st.transfer_date AS sort_date, st.transfer_date AS sort_date,
TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text,
st.movement_number AS reference_number, st.movement_number AS reference_number,
'Internal Transfer In' AS transaction_type, 'Mutasi' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -538,7 +561,7 @@ SELECT
std.usage_qty AS quantity, std.usage_qty AS quantity,
u.id AS unit_id, u.id AS unit_id,
u.name AS unit, u.name AS unit,
'Stock Refill' AS notes st.reason AS notes
FROM stock_transfer_details std FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
@@ -548,13 +571,63 @@ JOIN uoms u ON u.id = prod.uom_id
WHERE st.to_warehouse_id IN ? 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 = ` sapronakOutgoingTransfersSQL = `
SELECT SELECT
CAST(st.id AS BIGINT) AS id, CAST(st.id AS BIGINT) AS id,
st.transfer_date AS sort_date, st.transfer_date AS sort_date,
TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text,
st.movement_number AS reference_number, st.movement_number AS reference_number,
'Internal Transfer Out' AS transaction_type, 'Mutasi' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -588,7 +661,7 @@ SELECT
std.usage_qty AS quantity, std.usage_qty AS quantity,
u.id AS unit_id, u.id AS unit_id,
u.name AS unit, u.name AS unit,
'Transfer to other unit' AS notes st.reason AS notes
FROM stock_transfer_details std FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
@@ -598,13 +671,70 @@ JOIN uoms u ON u.id = prod.uom_id
WHERE st.from_warehouse_id IN ? 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 = ` sapronakOutgoingMarketingsSQL = `
SELECT SELECT
CAST(mp.id AS BIGINT) AS id, CAST(mp.id AS BIGINT) AS id,
m.so_date AS sort_date, m.so_date AS sort_date,
TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text, TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text,
m.so_number AS reference_number, m.so_number AS reference_number,
'Trading Sales' AS transaction_type, 'Penjualan' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -652,7 +782,7 @@ WHERE pw.project_flock_kandang_id IN ?
FROM flags f FROM flags f
WHERE f.flagable_id = pw.product_id WHERE f.flagable_id = pw.product_id
AND f.flagable_type = 'products' 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')
) )
` `
) )
@@ -687,6 +817,16 @@ type SapronakDetailRow struct {
func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) } func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) }
func applyDateRange(db *gorm.DB, column string, start, end *time.Time) *gorm.DB {
if start != nil {
db = db.Where(column+"::date >= ?", start)
}
if end != nil {
db = db.Where(column+"::date <= ?", end)
}
return db
}
func applyJoins(db *gorm.DB, joins ...string) *gorm.DB { func applyJoins(db *gorm.DB, joins ...string) *gorm.DB {
for _, j := range joins { for _, j := range joins {
if strings.TrimSpace(j) != "" { if strings.TrimSpace(j) != "" {
@@ -787,6 +927,14 @@ func (r *ClosingRepositoryImpl) fetchSapronakUsage(
return rows, nil return rows, nil
} }
func scanUsage(db *gorm.DB) ([]SapronakUsageRow, error) {
rows := make([]SapronakUsageRow, 0)
if err := db.Group("pw.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *ClosingRepositoryImpl) detailQuery( func (r *ClosingRepositoryImpl) detailQuery(
ctx context.Context, ctx context.Context,
table string, table string,
@@ -818,11 +966,11 @@ func (r *ClosingRepositoryImpl) fetchSapronakDetails(
return scanAndGroupDetails(r.detailQuery(ctx, table, pwJoinCond, joins, selectSQL, where, args...)) return scanAndGroupDetails(r.detailQuery(ctx, table, pwJoinCond, joins, selectSQL, where, args...))
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) {
if pfkID == 0 { if pfkID == 0 {
return nil, nil return nil, nil
} }
return r.fetchSapronakUsage( db := r.usageQuery(
ctx, ctx,
"recording_stocks rs", "recording_stocks rs",
"pw.id = rs.product_warehouse_id", "pw.id = rs.product_warehouse_id",
@@ -831,13 +979,15 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui
pfkID, pfkID,
sapronakFlagsUsage, sapronakFlagsUsage,
) )
db = applyDateRange(db, "r.record_datetime", start, end)
return scanUsage(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) {
if pfkID == 0 { if pfkID == 0 {
return []SapronakUsageRow{}, nil return []SapronakUsageRow{}, nil
} }
return r.fetchSapronakUsage( db := r.usageQuery(
ctx, ctx,
"project_chickins pc", "project_chickins pc",
"pw.id = pc.product_warehouse_id", "pw.id = pc.product_warehouse_id",
@@ -846,10 +996,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, p
pfkID, pfkID,
sapronakFlagsChickin, sapronakFlagsChickin,
) )
db = applyDateRange(db, "pc.chick_in_date", start, end)
return scanUsage(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
return r.fetchSapronakDetails( db := r.detailQuery(
ctx, ctx,
"recording_stocks rs", "recording_stocks rs",
"pw.id = rs.product_warehouse_id", "pw.id = rs.product_warehouse_id",
@@ -868,10 +1020,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p
pfkID, pfkID,
sapronakFlagsUsage, sapronakFlagsUsage,
) )
db = applyDateRange(db, "r.record_datetime", start, end)
return scanAndGroupDetails(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
return r.fetchSapronakDetails( db := r.detailQuery(
ctx, ctx,
"project_chickins pc", "project_chickins pc",
"pw.id = pc.product_warehouse_id", "pw.id = pc.product_warehouse_id",
@@ -890,18 +1044,21 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Con
pfkID, pfkID,
sapronakFlagsChickin, sapronakFlagsChickin,
) )
db = applyDateRange(db, "pc.chick_in_date", start, end)
return scanAndGroupDetails(db)
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 { if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil return map[uint][]SapronakDetailRow{}, nil
} }
dateExpr := "COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime)"
query := r.withCtx(ctx). query := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
pw.product_id AS product_id, p_resolve.id AS product_id,
p.name AS product_name, p_resolve.name AS product_name,
f.name AS flag, f.name AS flag,
COALESCE( COALESCE(
pi.received_date, pi.received_date,
@@ -922,10 +1079,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
) AS reference, ) AS reference,
0 AS qty_in, 0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out, COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p.product_price, 0) AS price COALESCE(pi.price, p_resolve.product_price, 0) AS price
`). `).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()). Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()).
Joins("LEFT JOIN recordings r ON r.id = rs.recording_id"). Joins("LEFT JOIN recordings r ON r.id = rs.recording_id").
Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
@@ -935,31 +1091,36 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Joins("LEFT JOIN product_warehouses pw_pc ON pw_pc.id = pc.product_warehouse_id").
Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw_pc.product_id, pw.product_id)").
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where(` Where(`
(sa.usable_type = ? AND r.project_flock_kandangs_id = ?) (sa.usable_type = ? AND r.project_flock_kandangs_id = ? AND f.name IN ?)
OR OR
(sa.usable_type = ? AND pc_used.project_flock_kandang_id = ?) (sa.usable_type = ? AND pc_used.project_flock_kandang_id = ? AND f.name IN ?)
`, `,
fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, sapronakFlagsUsage,
fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, sapronakFlagsChickin,
) )
query = r.joinSapronakProductFlag(query, "p"). query = r.joinSapronakProductFlag(query, "p_resolve").
Group(` Group(`
pw.product_id, p.name, f.name, p_resolve.id, p_resolve.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime, pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime,
po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id, po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id,
pi.price, p.product_price pi.price, p_resolve.product_price
`) `)
query = applyDateRange(query, dateExpr, start, end)
return scanAndGroupDetails(query) return scanAndGroupDetails(query)
} }
func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint, start, end *time.Time) *gorm.DB {
db := r.withCtx(ctx). db := r.withCtx(ctx).
Table("purchase_items AS pi"). Table("purchase_items AS pi").
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
@@ -968,12 +1129,13 @@ func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandan
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL") Where("pi.received_date IS NOT NULL")
db = applyDateRange(db, "pi.received_date", start, end)
return r.joinSapronakProductFlag(db, "p") return r.joinSapronakProductFlag(db, "p")
} }
func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) {
rows := make([]SapronakIncomingRow, 0) rows := make([]SapronakIncomingRow, 0)
db := r.incomingPurchaseBase(ctx, kandangID).Select(` db := r.incomingPurchaseBase(ctx, kandangID, start, end).Select(`
pi.product_id AS product_id, pi.product_id AS product_id,
p.name AS product_name, p.name AS product_name,
f.name AS flag, f.name AS flag,
@@ -987,9 +1149,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kanda
return rows, nil return rows, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
return scanAndGroupDetails( return scanAndGroupDetails(
r.incomingPurchaseBase(ctx, kandangID).Select(` r.incomingPurchaseBase(ctx, kandangID, start, end).Select(`
pi.product_id AS product_id, pi.product_id AS product_id,
p.name AS product_name, p.name AS product_name,
f.name AS flag, f.name AS flag,
@@ -1084,16 +1246,82 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
return in, out return in, out
} }
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
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")
incomingQuery = applyDateRange(incomingQuery, "ast.created_at", start, end)
incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { return fmt.Sprintf("ADJ-%d", row.ID) })
return in, out, nil dateExpr := "COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at)"
outgoingQuery := r.withCtx(ctx).
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")
outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end)
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) { func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
incomingQuery := r.withCtx(ctx). incomingQuery := r.withCtx(ctx).
Table("stock_transfer_details AS std"). Table("stock_transfer_details AS std").
Select(` Select(`
@@ -1115,6 +1343,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)"). Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll) Where("f.name IN ?", sapronakFlagsAll)
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incomingQuery = applyDateRange(incomingQuery, "st.transfer_date", start, end)
incoming, err := scanAndGroupDetails(incomingQuery) incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1143,6 +1372,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)"). Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll) Where("f.name IN ?", sapronakFlagsAll)
incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p") incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p")
incomingLayingQuery = applyDateRange(incomingLayingQuery, "lt.transfer_date", start, end)
incomingLaying, err := scanAndGroupDetails(incomingLayingQuery) incomingLaying, err := scanAndGroupDetails(incomingLayingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1176,6 +1406,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoingQuery = applyDateRange(outgoingQuery, "st.transfer_date", start, end)
outgoing, err := scanAndGroupDetails(outgoingQuery) outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1207,6 +1438,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price")
outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p") outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p")
outgoingLayingQuery = applyDateRange(outgoingLayingQuery, "lt.transfer_date", start, end)
outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -1218,7 +1450,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
return incoming, outgoing, nil return incoming, outgoing, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
query := r.withCtx(ctx). query := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
@@ -1242,6 +1474,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
query = r.joinSapronakProductFlag(query, "p") query = r.joinSapronakProductFlag(query, "p")
query = applyDateRange(query, "COALESCE(mdp.delivery_date, mdp.created_at)", start, end)
sales, err := scanAndGroupDetails(query) sales, err := scanAndGroupDetails(query)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1274,6 +1507,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p") nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p")
nonFifoQuery = applyDateRange(nonFifoQuery, "COALESCE(mdp.delivery_date, mdp.created_at)", start, end)
nonFifoSales, err := scanAndGroupDetails(nonFifoQuery) nonFifoSales, err := scanAndGroupDetails(nonFifoQuery)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1286,6 +1520,116 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
return sales, nil return sales, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil
}
pfpType := fifo.StockableKeyProjectFlockPopulation.String()
dateExpr := fmt.Sprintf(`
CASE
WHEN sa.stockable_type = '%s' THEN COALESCE(
pi_pc.received_date,
st_pc.transfer_date,
lt_pc.transfer_date,
ast_pc.created_at,
pc.chick_in_date
)
ELSE COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
ast.created_at
)
END
`, pfpType)
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(fmt.Sprintf(`
p_resolve.id AS product_id,
p_resolve.name AS product_name,
f.name AS flag,
CASE
WHEN sa.stockable_type = '%s' THEN COALESCE(
pi_pc.received_date,
st_pc.transfer_date,
lt_pc.transfer_date,
ast_pc.created_at,
pc.chick_in_date
)
ELSE COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
ast.created_at
)
END AS date,
CASE
WHEN sa.stockable_type = '%s' THEN COALESCE(
po_pc.po_number,
st_pc.movement_number,
lt_pc.transfer_number,
CASE WHEN ast_pc.id IS NOT NULL THEN CONCAT('ADJ-', ast_pc.id) END,
CONCAT('CHICKIN-', pc.id),
''
)
ELSE COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
CASE WHEN ast.id IS NOT NULL THEN CONCAT('ADJ-', ast.id) END,
''
)
END AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
CASE
WHEN sa.stockable_type = '%s' THEN COALESCE(pi_pc.price, p_resolve.product_price, 0)
ELSE COALESCE(pi.price, p_resolve.product_price, 0)
END AS price
`, pfpType, pfpType, pfpType)).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()).
Joins("JOIN product_warehouses pw_sales ON pw_sales.id = mdp.product_warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN 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 product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Joins("LEFT JOIN stock_allocations sa_pc ON sa_pc.usable_type = ? AND sa_pc.usable_id = pc.id", fifo.UsableKeyProjectChickin.String()).
Joins("LEFT JOIN purchase_items pi_pc ON pi_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po_pc ON po_pc.id = pi_pc.purchase_id").
Joins("LEFT JOIN stock_transfer_details std_pc ON std_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st_pc ON st_pc.id = std_pc.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt_pc ON ltt_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt_pc ON lt_pc.id = ltt_pc.laying_transfer_id").
Joins("LEFT JOIN adjustment_stocks ast_pc ON ast_pc.id = sa_pc.stockable_id AND sa_pc.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN product_warehouses pw_pc ON pw_pc.id = pc.product_warehouse_id").
Joins(fmt.Sprintf("LEFT JOIN products p_resolve ON p_resolve.id = CASE WHEN sa.stockable_type = '%s' THEN pw_pc.product_id ELSE COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id) END", pfpType)).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.stockable_type <> ?", fifo.StockableKeyRecordingEgg.String()).
Where("pw_sales.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group(`
p_resolve.id, p_resolve.name, f.name,
pi_pc.received_date, st_pc.transfer_date, lt_pc.transfer_date, ast_pc.created_at, pc.chick_in_date,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at,
po_pc.po_number, st_pc.movement_number, lt_pc.transfer_number, ast_pc.id, pc.id,
po.po_number, st.movement_number, lt.transfer_number, ast.id,
pi_pc.price, pi.price, p_resolve.product_price, sa.stockable_type
`)
query = r.joinSapronakProductFlag(query, "p_resolve")
query = applyDateRange(query, dateExpr, start, end)
return scanAndGroupDetails(query)
}
func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) {
if len(productIDs) == 0 { if len(productIDs) == 0 {
return []entity.Product{}, nil return []entity.Product{}, nil
@@ -2,7 +2,6 @@ package service
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math" "math"
@@ -33,6 +32,14 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type activeKandangMetric struct {
ProjectFlockKandangID uint
ProjectFlockID uint
KandangID uint
Category string
Metric float64
}
type ClosingService interface { type ClosingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error)
GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
@@ -385,6 +392,11 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
} }
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
startDate, endDate, err := s.getSapronakDateRange(c.Context(), projectFlockID, params.KandangID)
if err != nil {
s.Log.Errorf("Failed to resolve sapronak date range for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve sapronak date range")
}
rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{ rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{
Type: params.Type, Type: params.Type,
WarehouseIDs: warehouseIDs, WarehouseIDs: warehouseIDs,
@@ -392,6 +404,8 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
Limit: params.Limit, Limit: params.Limit,
Offset: offset, Offset: offset,
Search: params.Search, Search: params.Search,
StartDate: startDate,
EndDate: endDate,
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -468,11 +482,19 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
} }
} }
startDate, endDate, err := s.getSapronakDateRange(c.Context(), projectFlockID, params.KandangID)
if err != nil {
s.Log.Errorf("Failed to resolve sapronak date range for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve sapronak date range")
}
rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{ rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{
Type: params.Type, Type: params.Type,
WarehouseIDs: warehouseIDs, WarehouseIDs: warehouseIDs,
ProjectFlockKandangIDs: projectFlockKandangIDs, ProjectFlockKandangIDs: projectFlockKandangIDs,
Search: params.Search, Search: params.Search,
StartDate: startDate,
EndDate: endDate,
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err) s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -542,6 +564,90 @@ func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFl
return ids, nil return ids, nil
} }
func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID uint, kandangID *uint) (*time.Time, *time.Time, error) {
db := s.Repository.DB().WithContext(ctx)
if kandangID != nil && *kandangID > 0 {
var pfk entity.ProjectFlockKandang
if err := db.Select("id, created_at, closed_at").First(&pfk, *kandangID).Error; err != nil {
return nil, nil, err
}
var minChickin *time.Time
if err := db.Table("project_chickins").
Select("MIN(chick_in_date)").
Where("project_flock_kandang_id = ?", pfk.Id).
Scan(&minChickin).Error; err != nil {
return nil, nil, err
}
start := pfk.CreatedAt
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
}
startDate := dateOnlyUTC(start)
var endDate *time.Time
if pfk.ClosedAt != nil {
d := dateOnlyUTC(*pfk.ClosedAt)
endDate = &d
}
return &startDate, endDate, nil
}
var minCreated time.Time
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MIN(created_at)").
Where("project_flock_id = ?", projectFlockID).
Scan(&minCreated).Error; err != nil {
return nil, nil, err
}
var minChickin *time.Time
if err := db.Table("project_chickins pc").
Select("MIN(pc.chick_in_date)").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id").
Where("pfk.project_flock_id = ?", projectFlockID).
Scan(&minChickin).Error; err != nil {
return nil, nil, err
}
start := minCreated
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
}
startDate := dateOnlyUTC(start)
var endDate *time.Time
var openCount int64
if err := db.Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ? AND closed_at IS NULL", projectFlockID).
Count(&openCount).Error; err != nil {
return nil, nil, err
}
if openCount == 0 {
var maxClosed *time.Time
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MAX(closed_at)").
Where("project_flock_id = ?", projectFlockID).
Scan(&maxClosed).Error; err != nil {
return nil, nil, err
}
if maxClosed != nil && !maxClosed.IsZero() {
d := dateOnlyUTC(*maxClosed)
endDate = &d
}
}
return &startDate, endDate, nil
}
func dateOnlyUTC(t time.Time) time.Time {
u := t.UTC()
return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
}
func formatQuantity(qty float64, uom string) string { func formatQuantity(qty float64, uom string) string {
qtyStr := strconv.FormatFloat(qty, 'f', -1, 64) qtyStr := strconv.FormatFloat(qty, 'f', -1, 64)
if uom == "" { if uom == "" {
@@ -616,38 +722,17 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
return nil, err return nil, err
} }
realizations, err = s.allocateFarmOverheadRealizations(c.Context(), projectFlockID, projectFlockKandangID, realizations)
if err != nil {
return nil, err
}
projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totalKandangCount := len(projectFlockKandangs) totalKandangCount := len(projectFlockKandangs)
// Build kandang count map for farm expense division
projectFlockKandangCountMap := make(map[uint]int)
projectFlockKandangCountMap[projectFlockID] = totalKandangCount
involvedProjectFlocks := make(map[uint]bool)
for _, realization := range realizations {
if realization.ExpenseNonstock != nil &&
realization.ExpenseNonstock.Expense != nil &&
realization.ExpenseNonstock.Expense.ProjectFlockId != nil {
var projectFlockIDs []uint
if err := json.Unmarshal([]byte(*realization.ExpenseNonstock.Expense.ProjectFlockId), &projectFlockIDs); err == nil {
for _, pfID := range projectFlockIDs {
if pfID != projectFlockID {
involvedProjectFlocks[pfID] = true
}
}
}
}
}
for pfID := range involvedProjectFlocks {
if pfKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), pfID); err == nil {
projectFlockKandangCountMap[pfID] = len(pfKandangs)
}
}
chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -688,11 +773,197 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
totalActualPopulation := totalChickinQty - totalDepletion totalActualPopulation := totalChickinQty - totalDepletion
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount, projectFlockKandangCountMap) result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount)
return &result, nil return &result, nil
} }
type activeKandangMetricRow struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProjectFlockID uint `gorm:"column:project_flock_id"`
KandangID uint `gorm:"column:kandang_id"`
Category string `gorm:"column:category"`
ChickinQty float64 `gorm:"column:chickin_qty"`
DepletionQty float64 `gorm:"column:depletion_qty"`
EggQty float64 `gorm:"column:egg_qty"`
}
func (s closingService) getActiveKandangMetrics(ctx context.Context, locationID uint, transactionDate time.Time) ([]activeKandangMetric, error) {
db := s.Repository.DB().WithContext(ctx)
rows := []activeKandangMetricRow{}
rawSQL := `
SELECT
pfk.id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pfk.kandang_id AS kandang_id,
pf.category AS category,
COALESCE((
SELECT SUM(pc.usage_qty)
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = pfk.id
AND pc.chick_in_date::date <= ?
), 0) AS chickin_qty,
COALESCE((
SELECT SUM(rd.qty)
FROM recording_depletions rd
JOIN recordings r ON r.id = rd.recording_id
WHERE r.project_flock_kandangs_id = pfk.id
AND r.record_datetime::date <= ?
), 0) AS depletion_qty,
COALESCE((
SELECT SUM(re.qty)
FROM recording_eggs re
JOIN recordings r2 ON r2.id = re.recording_id
WHERE r2.project_flock_kandangs_id = pfk.id
AND r2.record_datetime::date <= ?
), 0) AS egg_qty
FROM project_flock_kandangs pfk
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE pf.location_id = ?
AND (pfk.closed_at IS NULL OR pfk.closed_at::date > ?)
AND EXISTS (
SELECT 1
FROM project_chickins pc2
WHERE pc2.project_flock_kandang_id = pfk.id
AND pc2.chick_in_date::date <= ?
)
`
if err := db.Raw(rawSQL, transactionDate, transactionDate, transactionDate, locationID, transactionDate, transactionDate).Scan(&rows).Error; err != nil {
return nil, err
}
result := make([]activeKandangMetric, 0, len(rows))
for _, row := range rows {
metric := 0.0
switch strings.ToLower(strings.TrimSpace(row.Category)) {
case "growing":
metric = row.ChickinQty
case "laying":
metric = row.EggQty
default:
s.Log.Warnf("Unknown project flock category for overhead allocation: %s (pfk=%d)", row.Category, row.ProjectFlockKandangID)
}
result = append(result, activeKandangMetric{
ProjectFlockKandangID: row.ProjectFlockKandangID,
ProjectFlockID: row.ProjectFlockID,
KandangID: row.KandangID,
Category: row.Category,
Metric: metric,
})
}
return result, nil
}
func round2(value float64) float64 {
return math.Round(value*100) / 100
}
func allocateFarmLevelQty(totalQty float64, metrics []activeKandangMetric) map[uint]float64 {
allocations := make(map[uint]float64, len(metrics))
if totalQty == 0 || len(metrics) == 0 {
return allocations
}
totalMetric := 0.0
var maxMetric float64
var maxMetricID uint
for _, m := range metrics {
if m.Metric <= 0 {
continue
}
totalMetric += m.Metric
if m.Metric > maxMetric || maxMetricID == 0 {
maxMetric = m.Metric
maxMetricID = m.ProjectFlockKandangID
}
}
if totalMetric == 0 {
return allocations
}
sumRounded := 0.0
for _, m := range metrics {
if m.Metric <= 0 {
continue
}
portion := totalQty * (m.Metric / totalMetric)
rounded := round2(portion)
allocations[m.ProjectFlockKandangID] = rounded
sumRounded += rounded
}
diff := totalQty - sumRounded
if maxMetricID != 0 && diff != 0 {
allocations[maxMetricID] = round2(allocations[maxMetricID] + diff)
}
return allocations
}
func (s closingService) allocateFarmOverheadRealizations(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, realizations []entity.ExpenseRealization) ([]entity.ExpenseRealization, error) {
if len(realizations) == 0 {
return realizations, nil
}
cache := make(map[string][]activeKandangMetric)
allocated := make([]entity.ExpenseRealization, 0, len(realizations))
for _, realization := range realizations {
expenseNonstock := realization.ExpenseNonstock
if expenseNonstock == nil || expenseNonstock.Expense == nil {
allocated = append(allocated, realization)
continue
}
// If already bound to a specific project flock kandang, don't re-allocate.
if expenseNonstock.ProjectFlockKandangId != nil {
allocated = append(allocated, realization)
continue
}
expense := expenseNonstock.Expense
locationID := uint(expense.LocationId)
txDate := expense.RealizationDate
cacheKey := fmt.Sprintf("%d|%s", locationID, txDate.Format("2006-01-02"))
metrics, exists := cache[cacheKey]
if !exists {
var err error
metrics, err = s.getActiveKandangMetrics(ctx, locationID, txDate)
if err != nil {
return nil, err
}
cache[cacheKey] = metrics
}
allocations := allocateFarmLevelQty(realization.Qty, metrics)
allocatedQty := 0.0
if projectFlockKandangID != nil {
allocatedQty = allocations[*projectFlockKandangID]
} else {
for _, m := range metrics {
if m.ProjectFlockID == projectFlockID {
allocatedQty += allocations[m.ProjectFlockKandangID]
}
}
allocatedQty = round2(allocatedQty)
}
adj := realization
adj.Qty = allocatedQty
if adj.Qty == 0 {
adj.Price = realization.Price
}
allocated = append(allocated, adj)
}
return allocated, nil
}
func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) {
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil { if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
@@ -836,14 +1107,6 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
finalPopulation := population - claimCulling 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) age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID, kandangID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err)
@@ -893,7 +1156,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
chickenDepletion = 0 chickenDepletion = 0
} }
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age, standards) chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age)
if fcrActFromRecording != nil { if fcrActFromRecording != nil {
chickenPerformance.FcrAct = *fcrActFromRecording chickenPerformance.FcrAct = *fcrActFromRecording
} }
@@ -943,7 +1206,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
eggDepletion = 0 eggDepletion = 0
} }
eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age, standards) eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age)
if fcrActFromRecording != nil { if fcrActFromRecording != nil {
eggPerf.FcrAct = *fcrActFromRecording eggPerf.FcrAct = *fcrActFromRecording
} }
@@ -1001,10 +1264,10 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
performance.EggMass = eggMass performance.EggMass = eggMass
} }
} }
performance.DeffFcr = performance.FcrStd - performance.FcrAct
if productionStandardDetail != nil { if productionStandardDetail != nil {
if productionStandardDetail.StandardFCR != nil { if productionStandardDetail.StandardFCR != nil {
performance.FcrStd = *productionStandardDetail.StandardFCR performance.FcrStd = *productionStandardDetail.StandardFCR
performance.DeffFcr = performance.FcrStd - performance.FcrAct
} }
if !isGrowing { if !isGrowing {
if productionStandardDetail.TargetHenDayProduction != nil { if productionStandardDetail.TargetHenDayProduction != nil {
@@ -1091,8 +1354,8 @@ func (s closingService) determineProductionWeek(ctx context.Context, projectFloc
return week, nil return week, nil
} }
func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO { func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64) dto.ClosingPerformanceDTO {
mortalityStd, fcrStd := closestFcrValues(standards, averageWeight) mortalityStd, fcrStd := 0.0, 0.0
fcrAct := 0.0 fcrAct := 0.0
if totalWeight > 0 { if totalWeight > 0 {
@@ -1124,21 +1387,3 @@ func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopul
AwgAct: awg, 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
}
@@ -294,6 +294,9 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo
func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection { func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection {
actualPopulation := production.TotalPopulationIn - production.TotalDepletion actualPopulation := production.TotalPopulationIn - production.TotalDepletion
if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok {
actualPopulation = lastPopulation
}
totalWeightProduced := production.TotalWeightProduced totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg totalEggWeightKg := production.TotalEggWeightKg
@@ -529,6 +532,35 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
return dto.ToProfitLossSection(plItems, plSummary) return dto.ToProfitLossSection(plItems, plSummary)
} }
func (s closingKeuanganService) getLastPopulationFromRecordings(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) (float64, bool) {
if s.RecordingRepo == nil || len(projectFlockKandangs) == 0 {
return 0, false
}
total := 0.0
recordedCount := 0
for _, kandang := range projectFlockKandangs {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(c.Context(), kandang.Id)
if err != nil {
s.Log.Errorf("Failed to fetch latest recording for project_flock_kandang_id=%d: %+v", kandang.Id, err)
return 0, false
}
if latest == nil || latest.TotalChickQty == nil {
continue
}
recordedCount++
if *latest.TotalChickQty > 0 {
total += *latest.TotalChickQty
}
}
if recordedCount != len(projectFlockKandangs) {
return 0, false
}
return total, true
}
func containsFlag(flags []entity.Flag, name string) bool { func containsFlag(flags []entity.Flag, name string) bool {
for _, flag := range flags { for _, flag := range flags {
if flag.Name == name { if flag.Name == name {
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -18,8 +19,8 @@ import (
) )
type SapronakService interface { type SapronakService interface {
GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error)
GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error)
} }
type sapronakService struct { type sapronakService struct {
@@ -42,9 +43,9 @@ func NewSapronakService(
} }
} }
func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) { func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) {
if projectFlockID == 0 { if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required")
} }
reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
ProjectFlockID: projectFlockID, ProjectFlockID: projectFlockID,
@@ -52,19 +53,27 @@ func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint,
Flag: flag, Flag: flag,
}) })
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
if len(reports) <= 1 { if len(reports) <= 1 {
return reports, nil flags, err := s.collectProductFlags(c.Context(), reports)
if err != nil {
return nil, nil, err
}
return reports, flags, nil
} }
combined := s.combineSapronakReports(reports, projectFlockID) combined := s.combineSapronakReports(reports, projectFlockID)
return []dto.SapronakReportDTO{combined}, nil flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{combined})
if err != nil {
return nil, nil, err
}
return []dto.SapronakReportDTO{combined}, flags, nil
} }
func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) { func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) {
if projectFlockID == 0 || pfkID == 0 { if projectFlockID == 0 || pfkID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required")
} }
results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
@@ -74,16 +83,20 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint,
Flag: flag, Flag: flag,
}) })
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
for _, res := range results { for _, res := range results {
if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID { if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID {
return &res, nil flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{res})
if err != nil {
return nil, nil, err
}
return &res, flags, nil
} }
} }
return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") return nil, nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found")
} }
func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) {
@@ -111,7 +124,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
continue continue
} }
// We no longer filter by date for closing sapronak report; pass nil pointers. // Filter sapronak data by project flock period range.
items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag)
if err != nil { if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err)
@@ -136,6 +149,52 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
return results, nil return results, nil
} }
func (s sapronakService) collectProductFlags(ctx context.Context, reports []dto.SapronakReportDTO) (map[uint][]string, error) {
productIDs := make(map[uint]struct{})
for _, report := range reports {
for _, group := range report.Groups {
for _, item := range group.Items {
if item.ProductID > 0 {
productIDs[item.ProductID] = struct{}{}
}
}
}
}
if len(productIDs) == 0 {
return map[uint][]string{}, nil
}
ids := make([]uint, 0, len(productIDs))
for id := range productIDs {
ids = append(ids, id)
}
products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, ids)
if err != nil {
return nil, err
}
result := make(map[uint][]string, len(products))
for _, product := range products {
if len(product.Flags) == 0 {
continue
}
flags := make([]string, 0, len(product.Flags))
for _, flag := range product.Flags {
name := strings.TrimSpace(flag.Name)
if name == "" {
continue
}
flags = append(flags, strings.ToUpper(name))
}
if len(flags) > 0 {
result[product.Id] = flags
}
}
return result, nil
}
func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) {
db := s.ProjectFlockKandangRepo.DB().WithContext(ctx). db := s.ProjectFlockKandangRepo.DB().WithContext(ctx).
Preload("ProjectFlock"). Preload("ProjectFlock").
@@ -321,33 +380,33 @@ func buildSapronakDetails(
} }
func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) {
// For sapronak closing report we intentionally ignore date range // Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any).
// and aggregate all historical transactions for the kandang/project. startDate, endDate := sapronakPeriodRange(pfk)
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId) incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id) usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id) chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id) usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id) chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id) usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
@@ -355,15 +414,15 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
usageDetailsRows = usageAllocatedDetails usageDetailsRows = usageAllocatedDetails
chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{} chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{}
} }
adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId) adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId) transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
salesOutRows, err := s.Repository.FetchSapronakSales(ctx, pfk.Id) salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id, startDate, endDate)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
@@ -412,6 +471,7 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
// should not be counted yet. Only when category is LAYING we allow // should not be counted yet. Only when category is LAYING we allow
// pullet usage to contribute to qty_used. // pullet usage to contribute to qty_used.
isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying)) isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying))
hasChickin := len(pfk.Chickins) > 0
if !isLaying { if !isLaying {
filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows)) filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows))
@@ -433,11 +493,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
chickinUsageDetailsRows = filteredDetail chickinUsageDetailsRows = filteredDetail
} }
allUsageRows := append(usageRows, chickinUsageRows...)
incoming, usage := mapIncomingUsage(incomingRows, allUsageRows)
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
groupMap := make(map[string]*dto.SapronakGroupDTO)
for pid, rows := range chickinUsageDetailsRows { for pid, rows := range chickinUsageDetailsRows {
if len(rows) == 0 { if len(rows) == 0 {
continue continue
@@ -454,6 +509,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
transOutgoing := detailMaps.TransferOut transOutgoing := detailMaps.TransferOut
salesOutgoing := detailMaps.SalesOut salesOutgoing := detailMaps.SalesOut
allUsageRows := append(usageRows, chickinUsageRows...)
incoming, usage := mapIncomingUsage(incomingRows, allUsageRows)
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
groupMap := make(map[string]*dto.SapronakGroupDTO)
transIncoming = dedupTransfers(transIncoming) transIncoming = dedupTransfers(transIncoming)
transOutgoing = dedupTransfers(transOutgoing) transOutgoing = dedupTransfers(transOutgoing)
@@ -570,13 +630,12 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if existing.ProductName == "" { if existing.ProductName == "" {
existing.ProductName = d.ProductName existing.ProductName = d.ProductName
} }
existing.UsageQty += d.QtyKeluar // Adjustment keluar should reduce stock without inflating usage-based HPP.
existing.UsageValue += d.Nilai remaining := existing.IncomingQty - existing.UsageQty - d.QtyKeluar
if existing.IncomingQty >= existing.UsageQty { if remaining < 0 {
existing.RemainingQty = existing.IncomingQty - existing.UsageQty remaining = 0
} else {
existing.RemainingQty = 0
} }
existing.RemainingQty = remaining
itemMap[productID] = existing itemMap[productID] = existing
} }
} }
@@ -718,6 +777,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if !matchesFlag(flag) { if !matchesFlag(flag) {
continue continue
} }
if hasChickin && (strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER")) {
continue
}
group := ensureGroup(flag) group := ensureGroup(flag)
for _, d := range details { for _, d := range details {
if d.Flag == "" { if d.Flag == "" {
@@ -737,6 +799,10 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if !matchesFlag(flag) { if !matchesFlag(flag) {
continue continue
} }
// For chicken, we don't count sales as sapronak outflow.
if strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER") {
continue
}
group := ensureGroup(flag) group := ensureGroup(flag)
for _, d := range details { for _, d := range details {
if d.Flag == "" { if d.Flag == "" {
@@ -758,3 +824,20 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
return items, groups, totalIncoming, totalUsage, nil return items, groups, totalIncoming, totalUsage, nil
} }
func sapronakPeriodRange(pfk entity.ProjectFlockKandang) (*time.Time, *time.Time) {
if len(pfk.Chickins) == 0 {
start := dateOnlyUTC(pfk.CreatedAt)
return &start, pfk.ClosedAt
}
minDate := pfk.Chickins[0].ChickInDate
for _, c := range pfk.Chickins[1:] {
if c.ChickInDate.Before(minDate) {
minDate = c.ChickInDate
}
}
start := dateOnlyUTC(minDate)
return &start, pfk.ClosedAt
}
@@ -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) { func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) {
var rows []RecordingWeeklyMetric 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). db := r.DB().WithContext(ctx).
Table("recordings AS r"). 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.hen_day), 0) AS hen_day,
COALESCE(AVG(r.egg_weight), 0) AS egg_weight, COALESCE(AVG(r.egg_weight), 0) AS egg_weight,
COALESCE(AVG(r.feed_intake), 0) AS feed_intake, COALESCE(AVG(r.feed_intake), 0) AS feed_intake,
COALESCE(AVG(r.fcr_value), 0) AS fcr_value, 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 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 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.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL"). Where("r.deleted_at IS NULL").
Where("r.day IS NOT NULL AND r.day > 0") 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 return nil, nil
} }
filterClause := "" standardIDs := r.standardIDSubquery(filters)
filterArgs := make([]interface{}, 0) if standardIDs == nil {
if filters != nil { return nil, 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)
}
} }
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 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 return nil, err
} }
@@ -510,30 +444,6 @@ func (r *DashboardRepositoryImpl) standardIDSubquery(filters *validation.Dashboa
return db 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) { 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) seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType)
if err != nil { 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) { func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) {
var rows []EggQualityWeeklyMetric 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). db := r.DB().WithContext(ctx).
Table("recording_eggs AS re"). Table("recording_eggs AS re").
Select(` Select(fmt.Sprintf(`
((r.day - 1) / 7 + 1) AS week, %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 = ? 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(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.FlagTelurUtuh,
utils.FlagTelurPutih, utils.FlagTelurPutih,
utils.FlagTelurRetak, 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 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 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 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 product_warehouses AS pw ON pw.id = re.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_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). 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) { func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) {
var rows []WeeklyEggWeightMetric 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). db := r.DB().WithContext(ctx).
Table("recording_eggs AS re"). Table("recording_eggs AS re").
Select(` Select(fmt.Sprintf(`
((r.day - 1) / 7 + 1) AS week, %s AS week,
COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`). COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`, weekExpr)).
Joins("JOIN recordings AS r ON r.id = re.recording_id"). 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 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 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.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL"). Where("r.deleted_at IS NULL").
Where("r.day IS NOT NULL AND r.day > 0") 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) { func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) {
var rows []WeeklyFeedUsageMetric 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). db := r.DB().WithContext(ctx).
Table("recording_stocks AS rs"). Table("recording_stocks AS rs").
Select(` Select(fmt.Sprintf(`
((r.day - 1) / 7 + 1) AS week, %s AS week,
COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, 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 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 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 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 product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("JOIN uoms ON uoms.id = p.uom_id"). Joins("JOIN uoms ON uoms.id = p.uom_id").
@@ -69,23 +69,19 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex
Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id").
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id").
Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id").
Where("expenses.realization_date IS NOT NULL"). Where("expenses.realization_date IS NOT NULL").
Where("expenses.category = ?", "BOP") Where("expenses.category = ?", "BOP")
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
db = db.Where(`( db = db.Where(`(
expense_nonstocks.project_flock_kandang_id = ? OR expense_nonstocks.project_flock_kandang_id = ? OR
(expense_nonstocks.kandang_id = (SELECT kandang_id FROM project_flock_kandangs WHERE id = ?) AND
expense_nonstocks.project_flock_kandang_id IS NULL) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, *projectFlockKandangID, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID)) )`, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID))
} else { } else {
db = db.Where(`( db = db.Where(`(
project_flock_kandangs.project_flock_id = ? OR project_flock_kandangs.project_flock_id = ? OR
kandangs.id IN (SELECT kandang_id FROM project_flock_kandangs WHERE project_flock_id = ?) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, projectFlockID, projectFlockID, fmt.Sprintf("[%d]", projectFlockID)) )`, projectFlockID, fmt.Sprintf("[%d]", projectFlockID))
} }
err := db.Find(&realizations).Error err := db.Find(&realizations).Error
@@ -24,46 +24,81 @@ func NewTransactionController(transactionService service.TransactionService) *Tr
} }
func (u *TransactionController) GetAll(c *fiber.Ctx) error { 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, "")) raw := strings.TrimSpace(c.Query(key, ""))
if raw == "" { if raw == "" {
return nil, nil return nil, nil
} }
parsed, err := strconv.ParseUint(raw, 10, 64) parts := strings.Split(raw, ",")
if err != nil { ids := make([]uint, 0, len(parts))
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid "+key) 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 return nil, nil
} }
value := uint(parsed) return ids, nil
return &value, nil
} }
bankId, err := parseOptionalUint("bank_id") parseStringListParam := func(key string) ([]string, error) {
if err != nil { raw := strings.TrimSpace(c.Query(key, ""))
return err 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 { 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 { 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{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: c.Query("search", ""),
TransactionType: c.Query("transaction_type", ""), TransactionTypes: transactionTypes,
BankId: bankId, BankIDs: bankIDs,
CustomerId: customerId, CustomerIDs: customerIDs,
SupplierId: supplierId, SupplierIDs: supplierIDs,
SortDate: c.Query("sort_date", ""), SortDate: c.Query("sort_date", ""),
StartDate: c.Query("start_date", ""), StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""), EndDate: c.Query("end_date", ""),
} }
if query.Page < 1 || query.Limit < 1 { 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 != "" { if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(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( db = db.Where(
`LOWER(payment_code) LIKE ? OR `LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(payment_method, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR LOWER(COALESCE(transaction_type, '')) LIKE ? OR
LOWER(COALESCE(notes, '')) LIKE ?`, LOWER(COALESCE(notes, '')) LIKE ? OR
like, like, like, like, 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) != "" { if len(params.TransactionTypes) > 0 {
db = db.Where("transaction_type = ?", strings.ToUpper(strings.TrimSpace(params.TransactionType))) 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 { if len(params.BankIDs) > 0 {
db = db.Where("bank_id = ?", *params.BankId) 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( db = db.Where(
"(party_type = ? AND party_id = ?) OR (party_type = ? AND party_id = ?)", "(party_type = ? AND party_id IN ?) OR (party_type = ? AND party_id IN ?)",
string(utils.PaymentPartyCustomer), *params.CustomerId, string(utils.PaymentPartyCustomer), customerIDs,
string(utils.PaymentPartySupplier), *params.SupplierId, string(utils.PaymentPartySupplier), supplierIDs,
) )
} else if params.CustomerId != nil { } else if len(customerIDs) > 0 {
db = db.Where("party_type = ? AND party_id = ?", string(utils.PaymentPartyCustomer), *params.CustomerId) db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartyCustomer), customerIDs)
} else if params.SupplierId != nil { } else if len(supplierIDs) > 0 {
db = db.Where("party_type = ? AND party_id = ?", string(utils.PaymentPartySupplier), *params.SupplierId) db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartySupplier), supplierIDs)
} }
if startDate != nil { if startDate != nil {
@@ -1,22 +1,22 @@
package validation package validation
type Create struct { type Create struct {
Name string `json:"name" validate:"required_strict,min=3"` Name string `json:"name" validate:"required_strict,min=3"`
} }
type Update struct { type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"` Name *string `json:"name,omitempty" validate:"omitempty"`
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
TransactionType string `query:"transaction_type" validate:"omitempty,max=50"` TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"`
BankId *uint `query:"bank_id" validate:"omitempty,number,gt=0"` BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"`
CustomerId *uint `query:"customer_id" validate:"omitempty,number,gt=0"` CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
SupplierId *uint `query:"supplier_id" validate:"omitempty,number,gt=0"` SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"`
SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"` SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_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 ( import (
"context" "context"
"fmt"
"strconv"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type AdjustmentStockRepository interface { 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) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB DB() *gorm.DB
GenerateSequentialNumber(ctx context.Context, prefix string) (string, error)
} }
type adjustmentStockRepositoryImpl struct { type adjustmentStockRepositoryImpl struct {
@@ -50,3 +55,71 @@ func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepos
func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB { func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB {
return r.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{ adjustmentStock := &entity.AdjustmentStock{
ProductWarehouseId: productWarehouse.Id, 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 { if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
@@ -62,6 +62,7 @@ type StockLogDetailDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Increase float64 `json:"increase"` Increase float64 `json:"increase"`
Decrease float64 `json:"decrease"` Decrease float64 `json:"decrease"`
Stock float64 `json:"stock"`
LoggableType string `json:"loggable_type"` LoggableType string `json:"loggable_type"`
LoggableId uint `json:"loggable_id"` LoggableId uint `json:"loggable_id"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
@@ -195,6 +196,7 @@ func mapStockLogs(src []entity.StockLog) []StockLogDetailDTO {
Id: log.Id, Id: log.Id,
Increase: log.Increase, Increase: log.Increase,
Decrease: log.Decrease, Decrease: log.Decrease,
Stock: log.Stock,
LoggableType: log.LoggableType, LoggableType: log.LoggableType,
LoggableId: log.LoggableId, LoggableId: log.LoggableId,
Notes: notes, Notes: notes,
@@ -175,5 +175,47 @@ func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, err
s.Log.Errorf("Failed get product by id: %+v", err) s.Log.Errorf("Failed get product by id: %+v", err)
return nil, err return nil, err
} }
if len(product.ProductWarehouses) > 0 {
ids := make([]uint, 0, len(product.ProductWarehouses))
for _, pw := range product.ProductWarehouses {
if pw.Id != 0 {
ids = append(ids, pw.Id)
}
}
if len(ids) > 0 {
type pendingUsageRow struct {
ProductWarehouseId uint
PendingQty float64
}
var rows []pendingUsageRow
if err := s.ProductRepository.DB().WithContext(c.Context()).
Table("recording_stocks").
Select("product_warehouse_id, COALESCE(SUM(pending_qty), 0) AS pending_qty").
Where("pending_qty > 0").
Where("product_warehouse_id IN ?", ids).
Group("product_warehouse_id").
Scan(&rows).Error; err != nil {
s.Log.Errorf("Failed to load pending usage for product warehouses: %+v", err)
return nil, err
}
if len(rows) > 0 {
pendingMap := make(map[uint]float64, len(rows))
for _, row := range rows {
pendingMap[row.ProductWarehouseId] = row.PendingQty
}
for i := range product.ProductWarehouses {
pw := &product.ProductWarehouses[i]
if pending, ok := pendingMap[pw.Id]; ok && pending != 0 {
pw.Quantity -= pending
}
}
}
}
}
return product, nil return product, nil
} }
@@ -32,6 +32,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
Flags: c.Query("flags", ""), Flags: c.Query("flags", ""),
KandangId: uint(c.QueryInt("kandang_id", 0)), KandangId: uint(c.QueryInt("kandang_id", 0)),
TransferContext: c.Query(utils.TransferContextKey, ""), TransferContext: c.Query(utils.TransferContextKey, ""),
Type: c.Query("type", ""),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -6,6 +6,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
// === DTO Structs === // === DTO Structs ===
@@ -22,6 +23,7 @@ type ProductWarehouseListDTO struct {
Product *productDTO.ProductRelationDTO `json:"product,omitempty"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"`
Week int `json:"week"`
CreatedUser *UserRelationDTO `json:"created_user,omitempty"` CreatedUser *UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -109,6 +111,22 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
} }
dto.ProjectFlockKandang = pfkDTO dto.ProjectFlockKandang = pfkDTO
// Calculate week for AYAM_PULLET/AYAM products
productFlags := make([]string, len(e.Product.Flags))
for i, f := range e.Product.Flags {
productFlags[i] = f.Name
}
var category string
if e.ProjectFlockKandang.ProjectFlock.Id != 0 {
category = e.ProjectFlockKandang.ProjectFlock.Category
}
now := time.Now()
_, ageInWeeks := calculateAgeFromChickin(e.ProjectFlockKandang, &now, productFlags, category)
dto.Week = ageInWeeks
} }
return dto return dto
@@ -138,3 +156,58 @@ func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNeste
Warehouse: &warehouse, Warehouse: &warehouse,
} }
} }
// Helper function to calculate age from chickin (same logic as closingMarketing.dto.go)
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, currentDate *time.Time, productFlags []string, category string) (int, int) {
if projectFlockKandang == nil || currentDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0, 0
}
// Return 0 for TRADING, TELUR, and AYAM flags (only AYAM_PULLET should have week)
for _, flag := range productFlags {
if flag == string(utils.FlagOVK) ||
flag == string(utils.FlagPakan) ||
flag == string(utils.FlagPreStarter) ||
flag == string(utils.FlagStarter) ||
flag == string(utils.FlagFinisher) ||
flag == string(utils.FlagObat) ||
flag == string(utils.FlagVitamin) ||
flag == string(utils.FlagKimia) ||
flag == string(utils.FlagEkspedisi) ||
flag == string(utils.FlagTelur) ||
flag == string(utils.FlagTelurUtuh) ||
flag == string(utils.FlagTelurPecah) ||
flag == string(utils.FlagTelurPutih) ||
flag == string(utils.FlagTelurRetak) ||
flag == string(utils.FlagAyamAfkir) ||
flag == string(utils.FlagAyamCulling) ||
flag == string(utils.FlagAyamMati) {
return 0, 0
}
}
// Find earliest chickin date
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
for _, chickin := range projectFlockKandang.Chickins {
if chickin.ChickInDate.Before(earliestChickinDate) {
earliestChickinDate = chickin.ChickInDate
}
}
diff := currentDate.Sub(earliestChickinDate)
ageInDays := int(diff.Hours() / 24)
var ageInWeeks int
if ageInDays <= 0 {
ageInWeeks = 0
} else {
if category == string(utils.ProjectFlockCategoryLaying) {
ageInDays = ageInDays + 119
ageInWeeks = ((ageInDays - 1) / 7) + 1
} else {
ageInWeeks = ((ageInDays - 1) / 7) + 1
}
}
return ageInDays, ageInWeeks
}
@@ -168,9 +168,10 @@ func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []s
} }
return db. return db.
Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products"). Joins("JOIN flags f_flag ON f_flag.flagable_id = p_flag.id AND f_flag.flagable_type = ?", "products").
Where("flags.name IN ?", flags) Where("f_flag.name IN ?", flags).
Distinct()
} }
func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error { func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error {
@@ -46,7 +46,8 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Warehouse.Area"). Preload("Warehouse.Area").
Preload("Warehouse.Kandang"). Preload("Warehouse.Kandang").
Preload("ProjectFlockKandang"). Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.ProjectFlock") Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.Chickins")
} }
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
@@ -99,6 +100,16 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
var marketingTypes []string
if params.Type != "" {
marketingTypes = utils.ParseQueryArray(params.Type)
for _, t := range marketingTypes {
if !utils.IsValidMarketingType(t) {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type")
}
}
}
cleanFlags := utils.ParseFlags(params.Flags) cleanFlags := utils.ParseFlags(params.Flags)
productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
@@ -128,7 +139,48 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("warehouse_id = ?", params.WarehouseId) db = db.Where("warehouse_id = ?", params.WarehouseId)
} }
db = s.Repository.ApplyFlagsFilter(db, cleanFlags) if len(marketingTypes) > 0 {
flagSet := make(map[string]struct{})
for _, t := range marketingTypes {
switch t {
case string(utils.MarketingTypeAyamPullet):
flagSet[string(utils.FlagDOC)] = struct{}{}
flagSet[string(utils.FlagPullet)] = struct{}{}
flagSet[string(utils.FlagLayer)] = struct{}{}
case string(utils.MarketingTypeAyam):
flagSet[string(utils.FlagAyamAfkir)] = struct{}{}
flagSet[string(utils.FlagAyamCulling)] = struct{}{}
flagSet[string(utils.FlagAyamMati)] = struct{}{}
case string(utils.MarketingTypeTelur):
flagSet[string(utils.FlagTelur)] = struct{}{}
flagSet[string(utils.FlagTelurUtuh)] = struct{}{}
flagSet[string(utils.FlagTelurPecah)] = struct{}{}
flagSet[string(utils.FlagTelurPutih)] = struct{}{}
flagSet[string(utils.FlagTelurRetak)] = struct{}{}
case string(utils.MarketingTypeTrading):
flagSet[string(utils.FlagPakan)] = struct{}{}
flagSet[string(utils.FlagPreStarter)] = struct{}{}
flagSet[string(utils.FlagStarter)] = struct{}{}
flagSet[string(utils.FlagFinisher)] = struct{}{}
flagSet[string(utils.FlagOVK)] = struct{}{}
flagSet[string(utils.FlagObat)] = struct{}{}
flagSet[string(utils.FlagVitamin)] = struct{}{}
flagSet[string(utils.FlagKimia)] = struct{}{}
flagSet[string(utils.FlagEkspedisi)] = struct{}{}
}
}
if len(flagSet) > 0 {
flags := make([]string, 0, len(flagSet))
for f := range flagSet {
flags = append(flags, f)
}
db = s.Repository.ApplyFlagsFilter(db, flags)
}
}
if len(cleanFlags) > 0 {
db = s.Repository.ApplyFlagsFilter(db, cleanFlags)
}
return db.Order("product_warehouses.id DESC") return db.Order("product_warehouses.id DESC")
}) })
@@ -20,4 +20,5 @@ type Query struct {
Flags string `query:"flags" validate:"omitempty"` Flags string `query:"flags" validate:"omitempty"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
Type string `query:"type" validate:"omitempty"`
} }
@@ -483,7 +483,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
} }
if len(stockLogs) > 0 { if len(stockLogs) > 0 {
latestStockLog := stockLogs[0] latestStockLog := stockLogs[0]
stockLogDecrease.Stock -= latestStockLog.Stock - stockLogDecrease.Decrease stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else { } else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease stockLogDecrease.Stock -= stockLogDecrease.Decrease
} }
@@ -3,6 +3,7 @@ package controller
import ( import (
"math" "math"
"strconv" "strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services"
@@ -23,9 +24,38 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer
} }
func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
parseUintListParam := func(param string) ([]uint, error) {
if param == "" {
return nil, nil
}
parts := strings.Split(param, ",")
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
}
ids = append(ids, uint(parsed))
}
return ids, nil
}
productIDs, err := parseUintListParam(c.Query("product_ids", ""))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
}
query := &validation.DeliveryOrderQuery{ query := &validation.DeliveryOrderQuery{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search", "")),
ProductIDs: productIDs,
Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "),
CustomerId: uint(c.QueryInt("customer_id", 0)),
MarketingId: uint(c.QueryInt("marketing_id", 0)), MarketingId: uint(c.QueryInt("marketing_id", 0)),
} }
@@ -2,7 +2,9 @@ package dto
import ( import (
"fmt" "fmt"
"math"
"sort" "sort"
"strings"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -16,6 +18,7 @@ import (
type MarketingRelationDTO struct { type MarketingRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
SoNumber string `json:"so_number"` SoNumber string `json:"so_number"`
DoNumber *string `json:"do_number"`
SoDate time.Time `json:"so_date"` SoDate time.Time `json:"so_date"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
} }
@@ -76,45 +79,69 @@ type DeliveryGroupDTO struct {
} }
type DeliveryMarketingProductDTO struct { type DeliveryMarketingProductDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
MarketingId uint `json:"marketing_id"` MarketingId uint `json:"marketing_id"`
ProductWarehouseId uint `json:"product_warehouse_id"` ProductWarehouseId uint `json:"product_warehouse_id"`
Qty float64 `json:"qty"` MarketingType string `json:"marketing_type"`
UnitPrice float64 `json:"unit_price"` Qty float64 `json:"qty"`
AvgWeight float64 `json:"avg_weight"` UnitPrice float64 `json:"unit_price"`
TotalWeight float64 `json:"total_weight"` AvgWeight float64 `json:"avg_weight"`
TotalPrice float64 `json:"total_price"` TotalWeight float64 `json:"total_weight"`
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` TotalPrice float64 `json:"total_price"`
VehicleNumber string `json:"vehicle_number,omitempty"` ConvertionUnit *string `json:"convertion_unit,omitempty"`
WeightPerConvertion *float64 `json:"weight_per_convertion,omitempty"`
TotalPeti *float64 `json:"total_peti,omitempty"`
Week *int `json:"week,omitempty"`
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
VehicleNumber string `json:"vehicle_number,omitempty"`
} }
func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO { func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO {
var doNumber *string
if doNumbers := collectDoNumbers(marketing); len(doNumbers) > 0 {
value := doNumbers[0]
doNumber = &value
}
return MarketingRelationDTO{ return MarketingRelationDTO{
Id: marketing.Id, Id: marketing.Id,
SoNumber: marketing.SoNumber, SoNumber: marketing.SoNumber,
DoNumber: doNumber,
SoDate: marketing.SoDate, SoDate: marketing.SoDate,
Notes: marketing.Notes, Notes: marketing.Notes,
} }
} }
func ToDeliveryMarketingProductDTO(e entity.MarketingProduct) DeliveryMarketingProductDTO { func ToDeliveryMarketingProductDTO(e entity.MarketingProduct, marketingType string) DeliveryMarketingProductDTO {
var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO
if e.ProductWarehouse.Id != 0 { if e.ProductWarehouse.Id != 0 {
mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse) mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse)
productWarehouse = &mapped productWarehouse = &mapped
} }
// Calculate total_peti only for TELUR marketing type
var totalPeti *float64
if marketingType == "TELUR" && e.ConvertionUnit != nil && *e.ConvertionUnit == "PETI" && e.WeightPerConvertion != nil && *e.WeightPerConvertion > 0 {
calculated := math.Floor(e.TotalWeight / *e.WeightPerConvertion)
totalPeti = &calculated
}
return DeliveryMarketingProductDTO{ return DeliveryMarketingProductDTO{
Id: e.Id, Id: e.Id,
MarketingId: e.MarketingId, MarketingId: e.MarketingId,
ProductWarehouseId: e.ProductWarehouseId, ProductWarehouseId: e.ProductWarehouseId,
Qty: e.Qty, MarketingType: marketingType,
UnitPrice: e.UnitPrice, Qty: e.Qty,
AvgWeight: e.AvgWeight, UnitPrice: e.UnitPrice,
TotalWeight: e.TotalWeight, AvgWeight: e.AvgWeight,
TotalPrice: e.TotalPrice, TotalWeight: e.TotalWeight,
ProductWarehouse: productWarehouse, TotalPrice: e.TotalPrice,
VehicleNumber: getVehicleNumber(e), ConvertionUnit: e.ConvertionUnit,
WeightPerConvertion: e.WeightPerConvertion,
TotalPeti: totalPeti,
Week: e.Week,
ProductWarehouse: productWarehouse,
VehicleNumber: getVehicleNumber(e),
} }
} }
@@ -161,10 +188,9 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M
if len(marketing.Products) > 0 { if len(marketing.Products) > 0 {
salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products))
for i, product := range marketing.Products { for i, product := range marketing.Products {
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType)
} }
} }
return MarketingListDTO{ return MarketingListDTO{
MarketingRelationDTO: ToMarketingRelationDTO(marketing), MarketingRelationDTO: ToMarketingRelationDTO(marketing),
Customer: customer, Customer: customer,
@@ -201,7 +227,7 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity
if len(marketing.Products) > 0 { if len(marketing.Products) > 0 {
salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products))
for i, product := range marketing.Products { for i, product := range marketing.Products {
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType)
} }
} }
@@ -221,7 +247,6 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity
mapped := approvalDTO.ToApprovalDTO(*marketing.LatestApproval) mapped := approvalDTO.ToApprovalDTO(*marketing.LatestApproval)
latestApproval = mapped latestApproval = mapped
} }
return MarketingDetailDTO{ return MarketingDetailDTO{
MarketingRelationDTO: ToMarketingRelationDTO(marketing), MarketingRelationDTO: ToMarketingRelationDTO(marketing),
SoDocs: marketing.SoDocs, SoDocs: marketing.SoDocs,
@@ -328,11 +353,46 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
} }
func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, warehouseId uint) string { func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, warehouseId uint) string {
dateStr := "" numberPrefix := soNumber
if deliveryDate != nil { if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(soNumber)), "SO-") {
dateStr = deliveryDate.Format("20060102") numberPrefix = "DO-" + soNumber[3:]
} }
return fmt.Sprintf("%s-%s-%d", soNumber, dateStr, warehouseId) return numberPrefix
}
func collectDoNumbers(marketing *entity.Marketing) []string {
if marketing == nil || len(marketing.Products) == 0 {
return nil
}
seen := make(map[string]struct{})
for _, product := range marketing.Products {
if product.DeliveryProduct == nil || product.DeliveryProduct.DeliveryDate == nil {
continue
}
warehouseID := product.ProductWarehouse.WarehouseId
if warehouseID == 0 && product.ProductWarehouse.Warehouse.Id != 0 {
warehouseID = product.ProductWarehouse.Warehouse.Id
}
if warehouseID == 0 {
continue
}
doNumber := GenerateDeliveryOrderNumber(marketing.SoNumber, product.DeliveryProduct.DeliveryDate, warehouseID)
if doNumber != "" {
seen[doNumber] = struct{}{}
}
}
if len(seen) == 0 {
return nil
}
result := make([]string, 0, len(seen))
for value := range seen {
result = append(result, value)
}
sort.Strings(result)
return result
} }
func getVehicleNumber(e entity.MarketingProduct) string { func getVehicleNumber(e entity.MarketingProduct) string {
@@ -1,6 +1,7 @@
package dto package dto
import ( import (
"math"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -10,13 +11,18 @@ import (
// === DTO Structs === // === DTO Structs ===
type MarketingProductDTO struct { type MarketingProductDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Qty float64 `json:"qty"` MarketingType string `json:"marketing_type"`
UnitPrice float64 `json:"unit_price"` Qty float64 `json:"qty"`
AvgWeight float64 `json:"avg_weight"` UnitPrice float64 `json:"unit_price"`
TotalWeight float64 `json:"total_weight"` AvgWeight float64 `json:"avg_weight"`
TotalPrice float64 `json:"total_price"` TotalWeight float64 `json:"total_weight"`
ProductWarehouse *productWarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` TotalPrice float64 `json:"total_price"`
ConvertionUnit *string `json:"convertion_unit,omitempty"`
WeightPerConvertion *float64 `json:"weight_per_convertion,omitempty"`
TotalPeti *float64 `json:"total_peti,omitempty"`
Week *int `json:"week,omitempty"`
ProductWarehouse *productWarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
} }
type SalesOrdersListDTO struct { type SalesOrdersListDTO struct {
@@ -29,7 +35,7 @@ type SalesOrdersListDTO struct {
// === Mapper Functions === // === Mapper Functions ===
func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO { func ToMarketingProductDTO(e entity.MarketingProduct, marketingType string) MarketingProductDTO {
var productWarehouse *productWarehouseDTO.ProductWarehousNestedDTO var productWarehouse *productWarehouseDTO.ProductWarehousNestedDTO
if e.ProductWarehouse.Id != 0 { if e.ProductWarehouse.Id != 0 {
@@ -37,21 +43,33 @@ func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO {
productWarehouse = &mapped productWarehouse = &mapped
} }
// Calculate total_peti only for TELUR marketing type
var totalPeti *float64
if marketingType == "TELUR" && e.ConvertionUnit != nil && *e.ConvertionUnit == "PETI" && e.WeightPerConvertion != nil && *e.WeightPerConvertion > 0 {
calculated := math.Floor(e.TotalWeight / *e.WeightPerConvertion)
totalPeti = &calculated
}
return MarketingProductDTO{ return MarketingProductDTO{
Id: e.Id, Id: e.Id,
Qty: e.Qty, MarketingType: marketingType,
UnitPrice: e.UnitPrice, Qty: e.Qty,
AvgWeight: e.AvgWeight, UnitPrice: e.UnitPrice,
TotalWeight: e.TotalWeight, AvgWeight: e.AvgWeight,
TotalPrice: e.TotalPrice, TotalWeight: e.TotalWeight,
ProductWarehouse: productWarehouse, TotalPrice: e.TotalPrice,
ConvertionUnit: e.ConvertionUnit,
WeightPerConvertion: e.WeightPerConvertion,
TotalPeti: totalPeti,
Week: e.Week,
ProductWarehouse: productWarehouse,
} }
} }
func ToSalesOrdersListDTO(e entity.Marketing) SalesOrdersListDTO { func ToSalesOrdersListDTO(e entity.Marketing) SalesOrdersListDTO {
products := make([]MarketingProductDTO, len(e.Products)) products := make([]MarketingProductDTO, len(e.Products))
for i, p := range e.Products { for i, p := range e.Products {
products[i] = ToMarketingProductDTO(p) products[i] = ToMarketingProductDTO(p, e.MarketingType)
} }
return SalesOrdersListDTO{ return SalesOrdersListDTO{
@@ -68,7 +86,7 @@ func ToSalesOrdersListDTOFromMarketing(e entity.Marketing) SalesOrdersListDTO {
if len(e.Products) > 0 { if len(e.Products) > 0 {
salesOrder = make([]MarketingProductDTO, len(e.Products)) salesOrder = make([]MarketingProductDTO, len(e.Products))
for i, product := range e.Products { for i, product := range e.Products {
salesOrder[i] = ToMarketingProductDTO(product) salesOrder[i] = ToMarketingProductDTO(product, e.MarketingType)
} }
} }
@@ -4,8 +4,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -65,6 +65,7 @@ func (s deliveryOrdersService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Customer"). Preload("Customer").
Preload("SalesPerson"). Preload("SalesPerson").
Preload("Products.ProductWarehouse.Product"). Preload("Products.ProductWarehouse.Product").
Preload("Products.ProductWarehouse.Product.Uom").
Preload("Products.ProductWarehouse.Warehouse"). Preload("Products.ProductWarehouse.Warehouse").
Preload("Products.DeliveryProduct") Preload("Products.DeliveryProduct")
} }
@@ -111,9 +112,87 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
Preload("Customer"). Preload("Customer").
Preload("SalesPerson"). Preload("SalesPerson").
Preload("Products.ProductWarehouse.Product"). Preload("Products.ProductWarehouse.Product").
Preload("Products.ProductWarehouse.Product.Uom").
Preload("Products.ProductWarehouse.Warehouse"). Preload("Products.ProductWarehouse.Warehouse").
Preload("Products.DeliveryProduct") Preload("Products.DeliveryProduct")
if params.Status != "" {
status := strings.TrimSpace(params.Status)
latestApprovalSubQuery := s.MarketingRepo.DB().
WithContext(c.Context()).
Table("approvals").
Select("DISTINCT ON (approvable_id) approvable_id, step_name, action").
Where("approvable_type = ?", utils.ApprovalWorkflowMarketing.String()).
Order("approvable_id, id DESC")
if strings.EqualFold(status, "DITOLAK") {
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = marketings.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
} else {
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = marketings.id
AND LOWER(latest_approval.step_name) = LOWER(?)
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
)`, latestApprovalSubQuery, status, string(entity.ApprovalActionRejected))
}
}
if params.Search != "" {
searchPattern := "%" + params.Search + "%"
db = db.Where(`(
marketings.so_number ILIKE ? OR
EXISTS (
SELECT 1
FROM customers c
WHERE c.id = marketings.customer_id
AND c.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM users su
WHERE su.id = marketings.sales_person_id
AND su.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products p ON p.id = pw.product_id
WHERE mp.marketing_id = marketings.id
AND p.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE mp.marketing_id = marketings.id
AND w.name ILIKE ?
)
)`, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern)
}
if len(params.ProductIDs) > 0 {
db = db.Where(`EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products p ON p.id = pw.product_id
WHERE mp.marketing_id = marketings.id
AND p.id IN ?
)`, params.ProductIDs)
}
if params.CustomerId != 0 {
db = db.Where("marketings.customer_id = ?", params.CustomerId)
}
if scope.Restrict { if scope.Restrict {
if len(scope.IDs) == 0 { if len(scope.IDs) == 0 {
return db.Where("1 = 0") return db.Where("1 = 0")
@@ -237,6 +316,12 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction)
marketing, err := marketingRepoTx.GetByID(c.Context(), req.MarketingId, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing")
}
allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId) allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId)
if err != nil { if err != nil {
@@ -283,25 +368,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
itemDeliveryDate = &parsedDate itemDeliveryDate = &parsedDate
} }
isPakanOrOVK := false totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week)
if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 {
for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight
var totalPrice float64
if isPakanOrOVK {
totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice
} else {
totalPrice = totalWeight * requestedProduct.UnitPrice
}
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.UnitPrice = requestedProduct.UnitPrice
@@ -374,6 +441,12 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction)
marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing")
}
allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id) allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -421,25 +494,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
itemDeliveryDate = deliveryProduct.DeliveryDate itemDeliveryDate = deliveryProduct.DeliveryDate
} }
isPakanOrOVK := false totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week)
if foundMarketingProduct.ProductWarehouse.Id != 0 && foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 {
for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight
var totalPrice float64
if isPakanOrOVK {
totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice
} else {
totalPrice = totalWeight * requestedProduct.UnitPrice
}
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.UnitPrice = requestedProduct.UnitPrice
@@ -483,6 +538,20 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return s.getMarketingWithDeliveries(c, id) return s.getMarketingWithDeliveries(c, id)
} }
func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) {
if marketingType == string(utils.MarketingTypeTrading) {
totalWeight = 0
totalPrice = qty * unitPrice
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
totalWeight = qty * avgWeight
totalPrice = unitPrice * float64(*week) * qty
} else {
totalWeight = qty * avgWeight
totalPrice = totalWeight * unitPrice
}
return totalWeight, totalPrice
}
func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64, actorID uint) error { func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64, actorID uint) error {
if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
@@ -491,11 +560,17 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
if deliveryProduct == nil || deliveryProduct.Id == 0 { if deliveryProduct == nil || deliveryProduct.Id == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found")
} }
if deliveryProduct.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Delivery product warehouse not found")
}
if deliveryProduct.ProductWarehouseId != marketingProduct.ProductWarehouseId {
return fiber.NewError(fiber.StatusBadRequest, "Delivery product warehouse mismatch with marketing product")
}
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyMarketingDelivery, UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: deliveryProduct.Id, UsableID: deliveryProduct.Id,
ProductWarehouseID: marketingProduct.ProductWarehouseId, ProductWarehouseID: deliveryProduct.ProductWarehouseId,
Quantity: requestedQty, Quantity: requestedQty,
AllowPending: false, AllowPending: false,
Tx: tx, Tx: tx,
@@ -516,12 +591,12 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
Decrease: result.UsageQuantity, Decrease: result.UsageQuantity,
LoggableType: string(utils.StockLogTypeMarketing), LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id, LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId, ProductWarehouseId: deliveryProduct.ProductWarehouseId,
CreatedBy: actorID, CreatedBy: actorID,
Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity), Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity),
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, deliveryProduct.ProductWarehouseId, 1)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
} }
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math"
"strings" "strings"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
@@ -69,6 +70,7 @@ func (s salesOrdersService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Customer"). Preload("Customer").
Preload("SalesPerson"). Preload("SalesPerson").
Preload("Products.ProductWarehouse.Product.Flags"). Preload("Products.ProductWarehouse.Product.Flags").
Preload("Products.ProductWarehouse.Product.Uom").
Preload("Products.ProductWarehouse.Warehouse") Preload("Products.ProductWarehouse.Warehouse")
} }
@@ -103,6 +105,25 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
return nil, err return nil, err
} }
// Validasi semua product harus punya marketing_type yang sama
if len(req.MarketingProducts) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "marketing_products is required")
}
firstMarketingType := req.MarketingProducts[0].MarketingType
if !utils.IsValidMarketingType(firstMarketingType) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Tipe penjualan tidak valid")
}
for i, item := range req.MarketingProducts {
if !utils.IsValidMarketingType(item.MarketingType) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tipe penjualan tidak valid pada produk ke-%d", i+1))
}
if item.MarketingType != firstMarketingType {
return nil, fiber.NewError(fiber.StatusBadRequest, "Semua produk harus memiliki tipe penjualan yang sama")
}
}
actorID, err := m.ActorIDFromContext(c) actorID, err := m.ActorIDFromContext(c)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -115,6 +136,12 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
} }
for _, item := range req.MarketingProducts { for _, item := range req.MarketingProducts {
if item.MarketingType != string(utils.MarketingTypeTrading) && item.AvgWeight == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Berat rata-rata harus diisi")
}
if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Unit konversi tidak valid")
}
if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil {
return nil, err return nil, err
} }
@@ -125,6 +152,31 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
} }
} }
requestedByWarehouse := make(map[uint]float64)
for _, item := range req.MarketingProducts {
if item.ProductWarehouseId == 0 {
continue
}
requestedByWarehouse[item.ProductWarehouseId] += item.Qty
}
for pwID, requestedQty := range requestedByWarehouse {
productWarehouse, err := s.ProductWarehouseRepo.GetDetailByID(c.Context(), pwID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", pwID))
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock availability")
}
availableQty := productWarehouse.Quantity
if availableQty+1e-6 < requestedQty {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Stok tidak mencukupi untuk gudang %d: diminta %.3f, tersedia %.3f", pwID, requestedQty, availableQty),
)
}
}
soDate, err := utils.ParseDateString(req.Date) soDate, err := utils.ParseDateString(req.Date)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
@@ -149,6 +201,7 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
SoDate: soDate, SoDate: soDate,
SalesPersonId: req.SalesPersonId, SalesPersonId: req.SalesPersonId,
Notes: req.Notes, Notes: req.Notes,
MarketingType: firstMarketingType,
CreatedBy: actorID, CreatedBy: actorID,
} }
if err := marketingRepoTx.CreateOne(c.Context(), marketing, nil); err != nil { if err := marketingRepoTx.CreateOne(c.Context(), marketing, nil); err != nil {
@@ -161,10 +214,9 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
if product.ProductWarehouseId != 0 { if product.ProductWarehouseId != 0 {
pwIDs = append(pwIDs, product.ProductWarehouseId) pwIDs = append(pwIDs, product.ProductWarehouseId)
} }
if err := s.createMarketingProductWithDelivery(c.Context(), marketing.Id, product, marketingProductRepoTx, invDeliveryRepoTx); err != nil { if err := s.createMarketingProductWithDelivery(c.Context(), marketing.Id, product.MarketingType, product, marketingProductRepoTx, invDeliveryRepoTx); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product")
} }
} }
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil {
return err return err
@@ -207,6 +259,23 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
return nil, err return nil, err
} }
// Validasi semua product harus punya marketing_type yang sama
if len(req.MarketingProducts) > 0 {
firstMarketingType := req.MarketingProducts[0].MarketingType
if !utils.IsValidMarketingType(firstMarketingType) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Tipe penjualan tidak valid")
}
for i, item := range req.MarketingProducts {
if !utils.IsValidMarketingType(item.MarketingType) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tipe penjualan tidak valid pada produk ke-%d", i+1))
}
if item.MarketingType != firstMarketingType {
return nil, fiber.NewError(fiber.StatusBadRequest, "Semua produk harus memiliki tipe penjualan yang sama")
}
}
}
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err return nil, err
} }
@@ -234,6 +303,12 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
if len(req.MarketingProducts) > 0 { if len(req.MarketingProducts) > 0 {
for _, item := range req.MarketingProducts { for _, item := range req.MarketingProducts {
if item.MarketingType != string(utils.MarketingTypeTrading) && item.AvgWeight == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Berat rata-rata harus diisi")
}
if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Unit konversi tidak valid")
}
if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil {
return nil, err return nil, err
} }
@@ -281,6 +356,9 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
if req.Notes != "" { if req.Notes != "" {
updateBody["notes"] = req.Notes updateBody["notes"] = req.Notes
} }
if len(req.MarketingProducts) > 0 {
updateBody["marketing_type"] = req.MarketingProducts[0].MarketingType
}
if len(updateBody) > 0 { if len(updateBody) > 0 {
if err := marketingRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { if err := marketingRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil {
@@ -309,38 +387,12 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
for _, rp := range req.MarketingProducts { for _, rp := range req.MarketingProducts {
if old, ok := oldByPW[rp.ProductWarehouseId]; ok { if old, ok := oldByPW[rp.ProductWarehouseId]; ok {
// Get product untuk cek flag PAKAN atau OVK totalWeight, totalPrice := s.calculatePriceByMarketingType(rp.MarketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week)
productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB {
return db.Preload("Product.Flags")
})
if err != nil {
return err
}
// Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false
if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 {
for _, flag := range productWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
totalWeight := rp.Qty * rp.AvgWeight
var totalPrice float64
if isPakanOrOVK {
totalPrice = rp.Qty * rp.UnitPrice
} else {
totalPrice = totalWeight * rp.UnitPrice
}
deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id) deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check delivery product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to check delivery product")
} }
if err == nil && deliveryProduct.Id != 0 { if err == nil && deliveryProduct.Id != 0 {
oldQty := old.Qty oldQty := old.Qty
newQty := rp.Qty newQty := rp.Qty
@@ -363,12 +415,15 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
} }
updateBody := map[string]any{ updateBody := map[string]any{
"product_warehouse_id": rp.ProductWarehouseId, "product_warehouse_id": rp.ProductWarehouseId,
"qty": rp.Qty, "qty": rp.Qty,
"unit_price": rp.UnitPrice, "unit_price": rp.UnitPrice,
"avg_weight": rp.AvgWeight, "avg_weight": rp.AvgWeight,
"total_weight": totalWeight, "total_weight": totalWeight,
"total_price": totalPrice, "total_price": totalPrice,
"convertion_unit": rp.ConvertionUnit,
"weight_per_convertion": rp.WeightPerConvertion,
"week": rp.Week,
} }
if err := marketingProductRepoTx.PatchOne(c.Context(), old.Id, updateBody, nil); err != nil { if err := marketingProductRepoTx.PatchOne(c.Context(), old.Id, updateBody, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product")
@@ -391,7 +446,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
} }
} }
} else { } else {
if err := s.createMarketingProductWithDelivery(c.Context(), id, rp, marketingProductRepoTx, invDeliveryRepoTx); err != nil { if err := s.createMarketingProductWithDelivery(c.Context(), id, rp.MarketingType, rp, marketingProductRepoTx, invDeliveryRepoTx); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product")
} }
} }
@@ -399,7 +454,6 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
for _, old := range oldProducts { for _, old := range oldProducts {
if _, ok := reqByPW[old.ProductWarehouseId]; !ok { if _, ok := reqByPW[old.ProductWarehouseId]; !ok {
deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id) deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing delivery product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing delivery product")
@@ -682,45 +736,21 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
return updated, nil return updated, nil
} }
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
// Get product untuk cek flag PAKAN atau OVK totalWeight, totalPrice := s.calculatePriceByMarketingType(marketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week)
productWarehouse, err := s.ProductWarehouseRepo.GetByID(ctx, rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB {
return db.Preload("Product.Flags")
})
if err != nil {
return err
}
// Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false
if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 {
for _, flag := range productWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
totalWeight := rp.Qty * rp.AvgWeight
var totalPrice float64
if isPakanOrOVK {
// PAKAN atau OVK: qty × unit_price
totalPrice = rp.Qty * rp.UnitPrice
} else {
// Produk lain: total_weight × unit_price
totalPrice = totalWeight * rp.UnitPrice
}
marketingProduct := &entity.MarketingProduct{ marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId, MarketingId: marketingId,
ProductWarehouseId: rp.ProductWarehouseId, ProductWarehouseId: rp.ProductWarehouseId,
Qty: rp.Qty, Qty: rp.Qty,
UnitPrice: rp.UnitPrice, UnitPrice: rp.UnitPrice,
AvgWeight: rp.AvgWeight, AvgWeight: rp.AvgWeight,
TotalWeight: totalWeight, TotalWeight: totalWeight,
TotalPrice: totalPrice, TotalPrice: totalPrice,
ConvertionUnit: rp.ConvertionUnit,
WeightPerConvertion: rp.WeightPerConvertion,
Week: rp.Week,
} }
if err := marketingProductRepo.CreateOne(ctx, marketingProduct, nil); err != nil { if err := marketingProductRepo.CreateOne(ctx, marketingProduct, nil); err != nil {
return err return err
@@ -744,3 +774,17 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
return nil return nil
} }
func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) {
if marketingType == string(utils.MarketingTypeTrading) {
totalWeight = 0
totalPrice = math.Round(qty*unitPrice*100) / 100
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
totalWeight = math.Round(qty*avgWeight*100) / 100
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100
} else {
totalWeight = math.Round(qty*avgWeight*100) / 100
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
}
return totalWeight, totalPrice
}
@@ -19,9 +19,13 @@ type DeliveryOrderUpdate struct {
} }
type DeliveryOrderQuery struct { type DeliveryOrderQuery struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` Search string `query:"search" validate:"omitempty,max=100"`
ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"`
Status string `query:"status" validate:"omitempty,max=50"`
CustomerId uint `query:"customer_id" validate:"omitempty,gt=0"`
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
} }
type DeliveryOrderApprove struct { type DeliveryOrderApprove struct {
@@ -9,11 +9,15 @@ type Create struct {
} }
type CreateMarketingProduct struct { type CreateMarketingProduct struct {
VehicleNumber string `json:"vehicle_number" validate:"required,min=1,max=50"` MarketingType string `json:"marketing_type" validate:"required,min=1,max=50"`
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,gt=0"` VehicleNumber string `json:"vehicle_number" validate:"required,min=1,max=50"`
UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` ConvertionUnit *string `json:"convertion_unit" validate:"omitempty,min=1,max=20"`
Qty float64 `json:"qty" validate:"required,gt=0"` WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gt=0"`
AvgWeight float64 `json:"avg_weight" validate:"required,gt=0"` Week *int `json:"week" validate:"omitempty,gt=0"`
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,gt=0"`
UnitPrice float64 `json:"unit_price" validate:"required,gt=0"`
Qty float64 `json:"qty" validate:"required,gt=0"`
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gt=0"`
} }
type Update struct { type Update struct {
@@ -24,10 +24,11 @@ func NewLocationController(locationService service.LocationService) *LocationCon
func (u *LocationController) GetAll(c *fiber.Ctx) error { func (u *LocationController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: c.Query("search", ""),
AreaId: c.QueryInt("area_id", 0), AreaId: c.QueryInt("area_id", 0),
HasLaying: c.QueryBool("has_laying", false),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -60,6 +60,17 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
if params.AreaId != 0 { if params.AreaId != 0 {
db = db.Where("area_id = ?", params.AreaId) db = db.Where("area_id = ?", params.AreaId)
} }
if params.HasLaying {
db = db.Where(`
EXISTS (
SELECT 1
FROM project_flocks pf
WHERE pf.location_id = locations.id
AND pf.category = ?
AND pf.deleted_at IS NULL
)
`, utils.ProjectFlockCategoryLaying)
}
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
@@ -13,8 +13,9 @@ type Update struct {
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` AreaId int `query:"area_id" validate:"omitempty,number,gt=0"`
HasLaying bool `query:"has_laying"`
} }
@@ -52,7 +52,22 @@ func (s productCategoryService) GetAll(c *fiber.Ctx, params *validation.Query) (
productCategories, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { productCategories, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
if params.Search != "" { if params.Search != "" {
return db.Where("name ILIKE ?", "%"+params.Search+"%") terms := splitSearchTerms(params.Search)
if len(terms) == 0 {
return db
}
if len(terms) == 1 {
return db.Where("name ILIKE ?", "%"+terms[0]+"%")
}
for i, term := range terms {
like := "%" + term + "%"
if i == 0 {
db = db.Where("name ILIKE ?", like)
} else {
db = db.Or("name ILIKE ?", like)
}
}
return db
} }
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
@@ -64,6 +79,20 @@ func (s productCategoryService) GetAll(c *fiber.Ctx, params *validation.Query) (
return productCategories, total, nil return productCategories, total, nil
} }
func splitSearchTerms(raw string) []string {
parts := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ';' || r == '|'
})
terms := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
terms = append(terms, trimmed)
}
}
return terms
}
func (s productCategoryService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductCategory, error) { func (s productCategoryService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductCategory, error) {
productCategory, err := s.Repository.GetByID(c.Context(), id, s.withRelations) productCategory, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -152,6 +152,23 @@ func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Crea
if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil {
return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err)
} }
} else if req.ProjectCategory == string(utils.ProjectFlockCategoryGrowing) {
if detailReq.ProductionStandardDetails != nil && detailReq.ProductionStandardDetails.StandardFCR != nil {
var zero float64 = 0
productionStandardDetail := &entity.ProductionStandardDetail{
ProductionStandardId: newStandard.Id,
Week: detailReq.Week,
TargetHenDayProduction: &zero,
TargetHenHouseProduction: &zero,
TargetEggWeight: &zero,
TargetEggMass: &zero,
StandardFCR: detailReq.ProductionStandardDetails.StandardFCR,
}
if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil {
return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err)
}
}
} }
standardGrowthDetail := &entity.StandardGrowthDetail{ standardGrowthDetail := &entity.StandardGrowthDetail{
@@ -265,6 +282,23 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat
if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil {
return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err)
} }
} else if projectCategory == "GROWING" {
if detailReq.ProductionStandardDetails != nil && detailReq.ProductionStandardDetails.StandardFCR != nil {
var zero float64 = 0
productionStandardDetail := &entity.ProductionStandardDetail{
ProductionStandardId: id,
Week: detailReq.Week,
TargetHenDayProduction: &zero,
TargetHenHouseProduction: &zero,
TargetEggWeight: &zero,
TargetEggMass: &zero,
StandardFCR: detailReq.ProductionStandardDetails.StandardFCR,
}
if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil {
return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err)
}
}
} }
standardGrowthDetail := &entity.StandardGrowthDetail{ standardGrowthDetail := &entity.StandardGrowthDetail{
@@ -5,7 +5,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
areaRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" areaRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
fcrRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto"
flockRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto"
kandangRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/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" 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" 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" pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
userRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
// === DTO Structs (ordered) === // === DTO Structs (ordered) ===
@@ -40,7 +40,7 @@ type ProjectFlockDTO struct {
Category string `json:"category"` Category string `json:"category"`
Flock *flockRelationDTO.FlockRelationDTO `json:"flock"` Flock *flockRelationDTO.FlockRelationDTO `json:"flock"`
Area *areaRelationDTO.AreaRelationDTO `json:"area"` Area *areaRelationDTO.AreaRelationDTO `json:"area"`
Fcr *fcrRelationDTO.FcrRelationDTO `json:"fcr"` StandardFcr *float64 `json:"standard_fcr"`
Location *locationRelationDTO.LocationRelationDTO `json:"location"` Location *locationRelationDTO.LocationRelationDTO `json:"location"`
} }
@@ -97,10 +97,6 @@ func ToAreaDTO(e entity.Area) areaRelationDTO.AreaRelationDTO {
return areaRelationDTO.ToAreaRelationDTO(e) return areaRelationDTO.ToAreaRelationDTO(e)
} }
func ToFcrDTO(e entity.Fcr) fcrRelationDTO.FcrRelationDTO {
return fcrRelationDTO.ToFcrRelationDTO(e)
}
func ToLocationDTO(e entity.Location) locationRelationDTO.LocationRelationDTO { func ToLocationDTO(e entity.Location) locationRelationDTO.LocationRelationDTO {
return locationRelationDTO.ToLocationRelationDTO(e) return locationRelationDTO.ToLocationRelationDTO(e)
} }
@@ -121,11 +117,6 @@ func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO {
mapped := areaRelationDTO.ToAreaRelationDTO(e.Area) mapped := areaRelationDTO.ToAreaRelationDTO(e.Area)
area = &mapped area = &mapped
} }
var fcr *fcrRelationDTO.FcrRelationDTO
if e.Fcr.Id != 0 {
mapped := fcrRelationDTO.ToFcrRelationDTO(e.Fcr)
fcr = &mapped
}
var location *locationRelationDTO.LocationRelationDTO var location *locationRelationDTO.LocationRelationDTO
if e.Location.Id != 0 { if e.Location.Id != 0 {
mapped := locationRelationDTO.ToLocationRelationDTO(e.Location) mapped := locationRelationDTO.ToLocationRelationDTO(e.Location)
@@ -137,7 +128,7 @@ func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO {
Category: e.Category, Category: e.Category,
Flock: flock, Flock: flock,
Area: area, Area: area,
Fcr: fcr, StandardFcr: resolveProjectFlockStandardFcr(e),
Location: location, Location: location,
} }
} }
@@ -222,6 +213,22 @@ func ToChickinListDTOs(e []entity.ProjectChickin) []ChickinListDTO {
return result 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 { func ToChickinSimpleDTOs(e []entity.ProjectChickin) []ChickinSimpleDTO {
result := make([]ChickinSimpleDTO, len(e)) result := make([]ChickinSimpleDTO, len(e))
for i, r := range e { for i, r := range e {
@@ -36,6 +36,7 @@ type ChickinService interface {
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error)
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error)
EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error
} }
type chickinService struct { type chickinService struct {
@@ -81,7 +82,7 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.Kandang.Pic"). Preload("ProjectFlockKandang.Kandang.Pic").
Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.Area"). Preload("ProjectFlockKandang.ProjectFlock.Area").
Preload("ProjectFlockKandang.ProjectFlock.Fcr"). Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard.ProductionStandardDetails").
Preload("ProjectFlockKandang.ProjectFlock.Location"). Preload("ProjectFlockKandang.ProjectFlock.Location").
Preload("ProjectFlockKandang.ProjectFlock.Location.Area") Preload("ProjectFlockKandang.ProjectFlock.Location.Area")
@@ -731,6 +732,30 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
return nil return nil
} }
func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in")
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui sehingga belum dapat membuat recording")
}
for _, population := range populations {
if population.TotalQty > 0 {
return nil
}
}
return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording")
}
func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error {
if len(deltas) == 0 { if len(deltas) == 0 {
return nil return nil
@@ -8,7 +8,6 @@ import (
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/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" 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" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/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" productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto"
@@ -31,7 +30,7 @@ type ProjectFlockDTO struct {
projectFlockDTO.ProjectFlockRelationDTO projectFlockDTO.ProjectFlockRelationDTO
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"` Category string `json:"category"`
Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` StandardFcr *float64 `json:"standard_fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
@@ -86,7 +85,7 @@ func toProjectFlockDTO(pf *projectFlockDTO.ProjectFlockListDTO) *ProjectFlockDTO
ProjectFlockRelationDTO: pf.ProjectFlockRelationDTO, ProjectFlockRelationDTO: pf.ProjectFlockRelationDTO,
Area: pf.Area, Area: pf.Area,
Category: pf.Category, Category: pf.Category,
Fcr: pf.Fcr, StandardFcr: pf.StandardFcr,
ProductionStandard: pf.ProductionStandard, ProductionStandard: pf.ProductionStandard,
Location: pf.Location, Location: pf.Location,
CreatedUser: pf.CreatedUser, CreatedUser: pf.CreatedUser,
@@ -308,25 +308,23 @@ func (s projectFlockKandangService) CheckClosing(c *fiber.Ctx, id uint) (*Closin
} }
for _, pw := range productWarehouses { for _, pw := range productWarehouses {
if pw.Quantity > 0 { category := ""
category := "" if pw.Product.ProductCategory.Id != 0 {
if pw.Product.ProductCategory.Id != 0 { category = pw.Product.ProductCategory.Name
category = pw.Product.ProductCategory.Name
}
uomName := ""
if pw.Product.Uom.Id != 0 {
uomName = pw.Product.Uom.Name
}
stockRemain = append(stockRemain, StockRemainingDetail{
FlagName: string(flagName),
ProductWarehouseId: pw.Id,
ProductId: pw.ProductId,
ProductName: pw.Product.Name,
ProductCategory: category,
Uom: uomName,
Quantity: pw.Quantity,
})
} }
uomName := ""
if pw.Product.Uom.Id != 0 {
uomName = pw.Product.Uom.Name
}
stockRemain = append(stockRemain, StockRemainingDetail{
FlagName: string(flagName),
ProductWarehouseId: pw.Id,
ProductId: pw.ProductId,
ProductName: pw.Product.Name,
ProductCategory: category,
Uom: uomName,
Quantity: pw.Quantity,
})
} }
} }
} }
@@ -585,7 +583,7 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati
} }
} }
if s.ApprovalSvc != nil { if s.ApprovalSvc != nil {
reopenAction := entity.ApprovalActionUpdated reopenAction := entity.ApprovalActionApproved
// Hindari duplikasi jika approval terakhir sudah Disetujui + Updated // Hindari duplikasi jika approval terakhir sudah Disetujui + Updated
latestPFK, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) latestPFK, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil)
if lerr != nil { if lerr != nil {
@@ -611,6 +609,31 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati
return nil, aerr return nil, aerr
} }
} }
// Pastikan approval project flock kembali ke Aktif
latestPF, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlock, pfk.ProjectFlockId, nil)
if lerr != nil {
return nil, lerr
}
shouldCreatePF := true
if latestPF != nil &&
latestPF.StepNumber == uint16(utils.ProjectFlockStepAktif) &&
latestPF.Action != nil && *latestPF.Action == reopenAction {
shouldCreatePF = false
}
if shouldCreatePF {
if _, aerr := s.ApprovalSvc.CreateApproval(
c.Context(),
utils.ApprovalWorkflowProjectFlock,
pfk.ProjectFlockId,
utils.ProjectFlockStepAktif,
&reopenAction,
actorID,
nil,
); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) {
return nil, aerr
}
}
} }
default: default:
return nil, fiber.NewError(fiber.StatusBadRequest, "action harus close atau unclose") return nil, fiber.NewError(fiber.StatusBadRequest, "action harus close atau unclose")
@@ -6,7 +6,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/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" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
@@ -28,7 +27,7 @@ type ProjectFlockListDTO struct {
ProjectFlockRelationDTO ProjectFlockRelationDTO
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"` Category string `json:"category"`
Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` StandardFcr *float64 `json:"standard_fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"`
@@ -43,6 +42,7 @@ type KandangWithProjectFlockIdDTO struct {
kandangDTO.KandangRelationDTO kandangDTO.KandangRelationDTO
ProjectFlockKandangId uint `json:"project_flock_kandang_id"` ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
Period int `json:"period"` Period int `json:"period"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
} }
type ProjectFlockDetailDTO struct { type ProjectFlockDetailDTO struct {
@@ -75,20 +75,28 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF
for i, kandang := range e.Kandangs { for i, kandang := range e.Kandangs {
var ( var (
pfkId uint pfkId uint
period int period int
closedAt *time.Time
) )
for _, kh := range e.KandangHistory { for _, kh := range e.KandangHistory {
if kh.KandangId == kandang.Id { if kh.KandangId == kandang.Id {
pfkId = kh.Id pfkId = kh.Id
period = kh.Period period = kh.Period
closedAt = kh.ClosedAt
break break
} }
} }
mapped := kandangDTO.ToKandangRelationDTO(kandang)
if closedAt != nil {
// Jangan ubah tabel kandang, hanya override status di response.
mapped.Status = string(utils.KandangStatusNonActive)
}
kandangSummaries[i] = KandangWithProjectFlockIdDTO{ kandangSummaries[i] = KandangWithProjectFlockIdDTO{
KandangRelationDTO: kandangDTO.ToKandangRelationDTO(kandang), KandangRelationDTO: mapped,
ProjectFlockKandangId: pfkId, ProjectFlockKandangId: pfkId,
Period: period, Period: period,
ClosedAt: closedAt,
} }
} }
} }
@@ -99,12 +107,6 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF
areaSummary = &mapped areaSummary = &mapped
} }
var fcrSummary *fcrDTO.FcrRelationDTO
if e.Fcr.Id != 0 {
mapped := fcrDTO.ToFcrRelationDTO(e.Fcr)
fcrSummary = &mapped
}
var productionStandardSummary *productionStandardDTO.ProductionStandardRelationDTO var productionStandardSummary *productionStandardDTO.ProductionStandardRelationDTO
if e.ProductionStandard.Id != 0 { if e.ProductionStandard.Id != 0 {
mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProductionStandard) mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProductionStandard)
@@ -129,7 +131,7 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF
Kandangs: kandangSummaries, Kandangs: kandangSummaries,
ProjectBudgets: ToProjectBudgetDTOs(e.Budgets), ProjectBudgets: ToProjectBudgetDTOs(e.Budgets),
Category: e.Category, Category: e.Category,
Fcr: fcrSummary, StandardFcr: resolveProjectFlockStandardFcr(e),
ProductionStandard: productionStandardSummary, ProductionStandard: productionStandardSummary,
Location: locationSummary, Location: locationSummary,
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
@@ -204,6 +206,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 { func ToProjectBudgetDTO(e entity.ProjectBudget) ProjectBudgetDTO {
var nonstockRef *nonstockDTO.NonstockRelationDTO var nonstockRef *nonstockDTO.NonstockRelationDTO
if e.Nonstock != nil && e.Nonstock.Id != 0 { if e.Nonstock != nil && e.Nonstock.Id != 0 {
@@ -5,7 +5,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/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" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/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" productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto"
@@ -22,7 +21,7 @@ type ProjectFlockWithPivotDTO struct {
ProjectFlockRelationDTO ProjectFlockRelationDTO
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"` Category string `json:"category"`
Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` StandardFcr *float64 `json:"standard_fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
ProductionStandardId uint `json:"production_standard_id"` ProductionStandardId uint `json:"production_standard_id"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
@@ -67,10 +66,6 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD
mapped := areaDTO.ToAreaRelationDTO(e.ProjectFlock.Area) mapped := areaDTO.ToAreaRelationDTO(e.ProjectFlock.Area)
pfLocal.Area = &mapped pfLocal.Area = &mapped
} }
if e.ProjectFlock.Fcr.Id != 0 {
mapped := fcrDTO.ToFcrRelationDTO(e.ProjectFlock.Fcr)
pfLocal.Fcr = &mapped
}
if e.ProjectFlock.ProductionStandard.Id != 0 { if e.ProjectFlock.ProductionStandard.Id != 0 {
mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProjectFlock.ProductionStandard) mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProjectFlock.ProductionStandard)
pfLocal.ProductionStandard = &mapped pfLocal.ProductionStandard = &mapped
@@ -83,6 +78,7 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD
mapped := userDTO.ToUserRelationDTO(e.ProjectFlock.CreatedUser) mapped := userDTO.ToUserRelationDTO(e.ProjectFlock.CreatedUser)
pfLocal.CreatedUser = &mapped pfLocal.CreatedUser = &mapped
} }
pfLocal.StandardFcr = resolveProjectFlockStandardFcr(e.ProjectFlock)
for _, k := range e.ProjectFlock.Kandangs { for _, k := range e.ProjectFlock.Kandangs {
kb := kandangDTO.ToKandangRelationDTO(k) kb := kandangDTO.ToKandangRelationDTO(k)
@@ -23,7 +23,6 @@ type ProjectflockRepository interface {
GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error)
IdExists(ctx context.Context, id uint) (bool, error) IdExists(ctx context.Context, id uint) (bool, error)
AreaExists(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) ProductionStandardExists(ctx context.Context, id uint) (bool, error)
LocationExists(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. return db.
Preload("CreatedUser"). Preload("CreatedUser").
Preload("Area"). Preload("Area").
Preload("Fcr").
Preload("ProductionStandard"). Preload("ProductionStandard").
Preload("ProductionStandard.ProductionStandardDetails").
Preload("Location"). Preload("Location").
Preload("Kandangs"). Preload("Kandangs").
Preload("KandangHistory"). Preload("KandangHistory").
@@ -134,14 +133,12 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s
likeQuery := "%" + normalized + "%" likeQuery := "%" + normalized + "%"
return db. return db.
Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). 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 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 locations ON locations.id = project_flocks.location_id").
Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by").
Where(` Where(`
LOWER(areas.name) LIKE ? LOWER(areas.name) LIKE ?
OR LOWER(project_flocks.category) LIKE ? OR LOWER(project_flocks.category) LIKE ?
OR LOWER(fcrs.name) LIKE ?
OR LOWER(production_standards.name) LIKE ? OR LOWER(production_standards.name) LIKE ?
OR LOWER(locations.name) LIKE ? OR LOWER(locations.name) LIKE ?
OR LOWER(locations.address) LIKE ? OR LOWER(locations.address) LIKE ?
@@ -172,7 +169,6 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s
likeQuery, likeQuery,
likeQuery, 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) 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) { func (r *ProjectflockRepositoryImpl) ProductionStandardExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.ProductionStandard](ctx, r.DB(), id) return repository.Exists[entity.ProductionStandard](ctx, r.DB(), id)
} }
@@ -13,6 +13,7 @@ import (
type ProjectFlockKandangRepository interface { type ProjectFlockKandangRepository interface {
GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error)
GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error)
GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error)
GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error)
UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error
@@ -117,10 +118,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex
Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\""). 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\""). Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\"").
Preload("ProjectFlock"). Preload("ProjectFlock").
Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area"). Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location"). Preload("ProjectFlock.Location").
Preload("ProjectFlock.CreatedUser"). Preload("ProjectFlock.CreatedUser").
Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails").
Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.Kandangs").
Preload("ProjectFlock.KandangHistory"). Preload("ProjectFlock.KandangHistory").
Preload("Kandang"). Preload("Kandang").
@@ -208,10 +209,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context.
Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\""). 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\""). Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\"").
Preload("ProjectFlock"). Preload("ProjectFlock").
Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area"). Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location"). Preload("ProjectFlock.Location").
Preload("ProjectFlock.CreatedUser"). Preload("ProjectFlock.CreatedUser").
Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails").
Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.Kandangs").
Preload("ProjectFlock.KandangHistory"). Preload("ProjectFlock.KandangHistory").
Preload("Kandang"). Preload("Kandang").
@@ -324,10 +325,10 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint
record := new(entity.ProjectFlockKandang) record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Preload("ProjectFlock"). Preload("ProjectFlock").
Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area"). Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location"). Preload("ProjectFlock.Location").
Preload("ProjectFlock.CreatedUser"). Preload("ProjectFlock.CreatedUser").
Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails").
Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.Kandangs").
Preload("ProjectFlock.KandangHistory"). Preload("ProjectFlock.KandangHistory").
Preload("Kandang"). Preload("Kandang").
@@ -342,15 +343,26 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint
return record, nil return record, nil
} }
// GetByIDLight loads only the minimal relations needed for recording flows.
func (r *projectFlockKandangRepositoryImpl) GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) {
record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx).
Preload("ProjectFlock").
First(record, id).Error; err != nil {
return nil, err
}
return record, nil
}
func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) {
record := new(entity.ProjectFlockKandang) record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID).
Preload("ProjectFlock"). Preload("ProjectFlock").
Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area"). Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location"). Preload("ProjectFlock.Location").
Preload("ProjectFlock.CreatedUser"). Preload("ProjectFlock.CreatedUser").
Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails").
Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.Kandangs").
Preload("ProjectFlock.KandangHistory"). Preload("ProjectFlock.KandangHistory").
Preload("Kandang"). Preload("Kandang").
@@ -48,6 +48,7 @@ type ProjectflockService interface {
GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error)
EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error
} }
type projectflockService struct { type projectflockService struct {
@@ -112,6 +113,32 @@ func (s projectflockService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
} }
} }
func (s projectflockService) EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error {
if projectFlockID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
approvalSvc := s.ApprovalSvc
if approvalSvc == nil {
approvalSvc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.Repository.DB()))
}
latest, err := approvalSvc.LatestByTarget(ctx, s.approvalWorkflow, projectFlockID, nil)
if err != nil {
s.Log.Errorf("Failed to check project flock %d approval status: %+v", projectFlockID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa status project flock")
}
if latest == nil {
return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording")
}
if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved {
return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording")
}
return nil
}
func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) { func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, nil, err return nil, 0, nil, err
@@ -282,7 +309,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
if err := commonSvc.EnsureRelations(c.Context(), if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists}, 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: "Production Standard", ID: &req.ProductionStandardId, Exists: s.Repository.ProductionStandardExists},
commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists}, commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists},
); err != nil { ); err != nil {
@@ -334,7 +360,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
createBody := &entity.ProjectFlock{ createBody := &entity.ProjectFlock{
AreaId: req.AreaId, AreaId: req.AreaId,
Category: cat, Category: cat,
FcrId: req.FcrId,
ProductionStandardId: req.ProductionStandardId, ProductionStandardId: req.ProductionStandardId,
LocationId: req.LocationId, LocationId: req.LocationId,
CreatedBy: actorID, CreatedBy: actorID,
@@ -461,6 +486,15 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
} }
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population")
}
if total > 0 {
return total, nil
}
if s.RecordingRepo != nil { if s.RecordingRepo != nil {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil { if err != nil {
@@ -472,12 +506,6 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr
} }
} }
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population")
}
return total, nil return total, nil
} }
@@ -552,21 +580,22 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt
} }
func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) {
if s.PopulationRepo == nil {
return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured")
}
wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID) pfk, err := s.PivotRepo.GetActiveByKandangID(ctx.Context(), kandangID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found")
}
return 0, err return 0, err
} }
productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(ctx.Context(), "DOC", wh.Id) total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), pfk.Id)
if err != nil { if err != nil {
return 0, err return 0, err
} }
total := 0.0
for _, pw := range productWarehouses {
total += pw.Quantity
}
return total, nil return total, nil
} }
@@ -4,7 +4,6 @@ type Create struct {
FlockName string `json:"flock_name" validate:"required_strict"` FlockName string `json:"flock_name" validate:"required_strict"`
AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"`
Category string `json:"category" validate:"required_strict"` 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"` ProductionStandardId uint `json:"production_standard_id" validate:"required_strict,number,gt=0"`
LocationId uint `json:"location_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"` KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"`
@@ -27,9 +27,14 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin
func (u *RecordingController) GetAll(c *fiber.Ctx) error { func (u *RecordingController) GetAll(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0) projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10)
offset := (page - 1) * limit
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: page,
Limit: c.QueryInt("limit", 10), Limit: limit,
Offset: offset,
Search: c.Query("search"), Search: c.Query("search"),
} }
if projectFlockID > 0 { if projectFlockID > 0 {
@@ -79,25 +84,27 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error {
} }
func (u *RecordingController) GetNextDay(c *fiber.Ctx) error { func (u *RecordingController) GetNextDay(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0) req := new(validation.GetRecordingNextDay)
if projectFlockID <= 0 {
if err := c.QueryParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid query params")
}
if req.ProjectFlockKandangId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
} }
if req.RecordTime == nil || strings.TrimSpace(*req.RecordTime) == "" {
recordTime := time.Now().UTC() return fiber.NewError(fiber.StatusBadRequest, "record_date is required")
if recordDate := strings.TrimSpace(c.Query("record_date")); recordDate != "" {
parsed, err := time.Parse("2006-01-02", recordDate)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format")
}
recordTime = parsed.UTC()
} }
recordTime, err := time.Parse("2006-01-02", strings.TrimSpace(*req.RecordTime))
nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID), recordTime) if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_time must be in YYYY-MM-DD format")
}
req.RecordTimeValue = &recordTime
nextDay, err := u.RecordingService.GetNextDay(c, req)
if err != nil { if err != nil {
return err return err
} }
projectFlockID := req.ProjectFlockKandangId
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{ JSON(response.Success{
Code: fiber.StatusOK, Code: fiber.StatusOK,
@@ -1,6 +1,7 @@
package dto package dto
import ( import (
"math"
"strings" "strings"
"time" "time"
@@ -73,7 +74,9 @@ type RecordingRelationDTO struct {
RecordDatetime time.Time `json:"record_datetime"` RecordDatetime time.Time `json:"record_datetime"`
Day int `json:"day"` Day int `json:"day"`
TotalDepletionQty float64 `json:"total_depletion_qty"` TotalDepletionQty float64 `json:"total_depletion_qty"`
TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"`
CumDepletionRate float64 `json:"cum_depletion_rate"` CumDepletionRate float64 `json:"cum_depletion_rate"`
DepletionRate float64 `json:"depletion_rate"`
CumIntake int `json:"cum_intake"` CumIntake int `json:"cum_intake"`
FcrValue float64 `json:"fcr_value"` FcrValue float64 `json:"fcr_value"`
HenDay float64 `json:"hen_day"` HenDay float64 `json:"hen_day"`
@@ -230,7 +233,9 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
RecordDatetime: e.RecordDatetime, RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day), Day: intValue(e.Day),
TotalDepletionQty: floatValue(e.TotalDepletionQty), 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), CumIntake: intValue(e.CumIntake),
FcrValue: floatValue(e.FcrValue), FcrValue: floatValue(e.FcrValue),
HenDay: floatValue(e.HenDay), 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{ result.Fcr = &RecordingFcrDTO{
Id: pfk.ProjectFlock.Fcr.Id, Id: pfk.ProjectFlock.ProductionStandard.Id,
Name: pfk.ProjectFlock.Fcr.Name, Name: pfk.ProjectFlock.ProductionStandard.Name,
FcrStd: floatValue(e.StandardFcr), FcrStd: floatValue(e.StandardFcr),
} }
} }
@@ -426,6 +431,17 @@ func floatValue(value *float64) float64 {
return *value 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 { func intValue(value *int) int {
if value == nil { if value == nil {
return 0 return 0
@@ -11,9 +11,17 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services"
rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
@@ -28,9 +36,18 @@ type RecordingModule struct{}
func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
recordingRepo := rRecording.NewRecordingRepository(db) recordingRepo := rRecording.NewRecordingRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
flockRepo := rFlock.NewFlockRepository(db)
kandangRepo := rKandang.NewKandangRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db)
productRepo := rProduct.NewProductRepository(db)
chickinRepo := rChickin.NewChickinRepository(db)
chickinDetailRepo := rChickin.NewChickinDetailRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
@@ -53,14 +70,30 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
ProductWarehouseID: "product_warehouse_id", ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty", TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used", TotalUsedQuantity: "total_used",
CreatedAt: "created_at", CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id)",
}, },
OrderBy: []string{"created_at ASC", "id ASC"}, OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id) ASC", "id ASC"},
}); err != nil { }); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") { if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err)) panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err))
} }
} }
if err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyRecordingDepletion,
Table: "recording_depletions",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "qty",
TotalUsedQuantity: "total_used_qty",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)",
},
OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id) ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording depletion stockable workflow: %v", err))
}
}
if err := fifoService.RegisterUsable(fifo.UsableConfig{ if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingStock, Key: fifo.UsableKeyRecordingStock,
Table: "recording_stocks", Table: "recording_stocks",
@@ -69,7 +102,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
ProductWarehouseID: "product_warehouse_id", ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty", UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty", PendingQuantity: "pending_qty",
CreatedAt: "id", CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_stocks.recording_id)",
}, },
}); err != nil { }); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") { if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
@@ -82,9 +115,9 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
Columns: fifo.UsableColumns{ Columns: fifo.UsableColumns{
ID: "id", ID: "id",
ProductWarehouseID: "source_product_warehouse_id", ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "qty", UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty", PendingQuantity: "pending_qty",
CreatedAt: "id", CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)",
}, },
ExcludedStockables: []fifo.StockableKey{ ExcludedStockables: []fifo.StockableKey{
fifo.StockableKeyTransferToLayingIn, fifo.StockableKeyTransferToLayingIn,
@@ -104,9 +137,41 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil { if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register recording approval workflow: %v", err)) panic(fmt.Sprintf("failed to register recording approval workflow: %v", err))
} }
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlock, utils.ProjectFlockApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err))
}
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
projectFlockService := sProjectFlock.NewProjectflockService(
projectFlockRepo,
flockRepo,
kandangRepo,
projectFlockKandangRepo,
warehouseRepo,
productWarehouseRepo,
projectBudgetRepo,
nonstockRepo,
projectFlockPopulationRepo,
recordingRepo,
approvalService,
validate,
)
chickinService := sChickin.NewChickinService(
chickinRepo,
kandangRepo,
warehouseRepo,
productWarehouseRepo,
productRepo,
projectFlockRepo,
projectFlockKandangRepo,
projectFlockPopulationRepo,
chickinDetailRepo,
validate,
fifoService,
)
recordingService := sRecording.NewRecordingService( recordingService := sRecording.NewRecordingService(
recordingRepo, recordingRepo,
projectFlockKandangRepo, projectFlockKandangRepo,
@@ -117,6 +182,8 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
fifoService, fifoService,
stockLogRepo, stockLogRepo,
productionStandardService, productionStandardService,
projectFlockService,
chickinService,
validate, validate,
) )
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -17,8 +17,13 @@ type RecordingRepository interface {
repository.BaseRepository[entity.Recording] repository.BaseRepository[entity.Recording]
WithRelations(db *gorm.DB) *gorm.DB WithRelations(db *gorm.DB) *gorm.DB
WithRelationsList(db *gorm.DB) *gorm.DB
ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB
GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error)
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error)
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
@@ -39,13 +44,18 @@ type RecordingRepository interface {
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error)
GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error)
GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error)
GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error)
FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error)
GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error)
GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error)
GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error)
GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error)
GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error)
GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error)
GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error)
GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, 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) GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error)
GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error)
@@ -54,6 +64,8 @@ type RecordingRepository interface {
GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error)
GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error)
GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error)
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
} }
type RecordingRepositoryImpl struct { type RecordingRepositoryImpl struct {
@@ -91,7 +103,7 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.Kandang.Location"). Preload("ProjectFlockKandang.Kandang.Location").
Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard"). Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard").
Preload("ProjectFlockKandang.ProjectFlock.Fcr"). // Preload("ProjectFlockKandang.ProjectFlock.Fcr").
Preload("Depletions"). Preload("Depletions").
Preload("Depletions.ProductWarehouse"). Preload("Depletions.ProductWarehouse").
Preload("Depletions.ProductWarehouse.Product"). Preload("Depletions.ProductWarehouse.Product").
@@ -112,6 +124,64 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("Eggs.ProductWarehouse.Warehouse.Location") Preload("Eggs.ProductWarehouse.Warehouse.Location")
} }
func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.Kandang").
Preload("ProjectFlockKandang.Kandang.Location").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard")
}
func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB {
db = r.WithRelationsList(db)
db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
if projectFlockKandangId != 0 {
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
}
db = r.ApplySearchFilters(db, search)
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
}
func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB {
db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
if projectFlockKandangId != 0 {
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
}
db = r.ApplySearchFilters(db, search)
return db
}
func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) {
var (
records []entity.Recording
total int64
)
countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId)
if modifier != nil {
countQ = modifier(countQ)
}
if err := countQ.Count(&total).Error; err != nil {
return nil, 0, err
}
listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId)
if modifier != nil {
listQ = modifier(listQ)
}
if err := listQ.Offset(offset).Limit(limit).Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}
func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB { func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB {
normalized := strings.ToLower(strings.TrimSpace(rawSearch)) normalized := strings.ToLower(strings.TrimSpace(rawSearch))
if normalized == "" { if normalized == "" {
@@ -169,6 +239,27 @@ func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.C
return &record, nil return &record, nil
} }
func (r *RecordingRepositoryImpl) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) {
if projectFlockKandangId == 0 {
return nil, errors.New("project_flock_kandang_id is required")
}
db := tx.WithContext(ctx).
Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangId).
Where("deleted_at IS NULL")
if from != nil {
db = db.Where("record_datetime >= ?", *from)
}
var records []entity.Recording
if err := db.Order("record_datetime ASC").Order("created_at ASC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) {
var days []int var days []int
if err := tx.Model(&entity.Recording{}). if err := tx.Model(&entity.Recording{}).
@@ -314,6 +405,85 @@ func (r *RecordingRepositoryImpl) SumRecordingDepletions(tx *gorm.DB, recordingI
return result, nil 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) GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error) {
result := make(map[uint]float64)
if len(recordingIDs) == 0 {
return result, nil
}
type row struct {
RecordingID uint `gorm:"column:recording_id"`
Total float64 `gorm:"column:total_qty"`
}
var rows []row
err := tx.
Table("recordings r").
Select("r.id AS recording_id, COALESCE(SUM(rd.qty), 0) AS total_qty").
Joins(`
LEFT JOIN recordings r2
ON r2.project_flock_kandangs_id = r.project_flock_kandangs_id
AND r2.record_datetime <= r.record_datetime
AND r2.deleted_at IS NULL`).
Joins("LEFT JOIN recording_depletions rd ON rd.recording_id = r2.id").
Where("r.id IN ?", recordingIDs).
Where("r.deleted_at IS NULL").
Group("r.id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
result[row.RecordingID] = row.Total
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error) {
if projectFlockKandangId == 0 || week <= 0 {
return 0, false, nil
}
var row struct {
ID uint
MeanUp float64
}
if err := tx.
Table("project_flock_kandang_uniformity").
Select("id, mean_up").
Where("project_flock_kandang_id = ?", projectFlockKandangId).
Where("week = ?", week).
Order("id DESC").
Limit(1).
Scan(&row).Error; err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
meanBw := row.MeanUp / 1.10
return meanBw, true, nil
}
func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) { func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) {
if currentDay <= 1 { if currentDay <= 1 {
return nil, nil return nil, nil
@@ -336,6 +506,46 @@ func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFloc
return &prev, nil return &prev, nil
} }
func (r *RecordingRepositoryImpl) GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error) {
result := make(map[uint]*float64)
if len(recordingIDs) == 0 {
return result, nil
}
type row struct {
RecordingID uint `gorm:"column:recording_id"`
PrevTotalChickQty *float64 `gorm:"column:prev_total_chick_qty"`
}
var rows []row
err := tx.
Table("recordings r").
Select(`
r.id AS recording_id,
(
SELECT r2.total_chick_qty
FROM recordings r2
WHERE r2.project_flock_kandangs_id = r.project_flock_kandangs_id
AND r2.day IS NOT NULL
AND r.day IS NOT NULL
AND r2.day < r.day
AND r2.deleted_at IS NULL
ORDER BY r2.day DESC
LIMIT 1
) AS prev_total_chick_qty`).
Where("r.id IN ?", recordingIDs).
Where("r.deleted_at IS NULL").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
result[row.RecordingID] = row.PrevTotalChickQty
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) { func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) {
var total float64 var total float64
err := tx. err := tx.
@@ -355,6 +565,57 @@ func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandang
return int64(math.Round(total)), nil return int64(math.Round(total)), nil
} }
func (r *RecordingRepositoryImpl) GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) {
var total float64
err := tx.
Table("project_flock_populations").
Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
if err != nil {
return 0, err
}
if total < 0 {
total = 0
}
return total, nil
}
func (r *RecordingRepositoryImpl) GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error) {
result := make(map[uint]int64)
if len(projectFlockKandangIds) == 0 {
return result, nil
}
type row struct {
ProjectFlockKandangId uint `gorm:"column:project_flock_kandang_id"`
Total float64 `gorm:"column:total_qty"`
}
var rows []row
err := tx.
Table("project_flock_populations pfp").
Select("project_chickins.project_flock_kandang_id, COALESCE(SUM(pfp.total_qty - pfp.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = pfp.project_chickin_id").
Where("project_chickins.project_flock_kandang_id IN ?", projectFlockKandangIds).
Group("project_chickins.project_flock_kandang_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
total := math.Round(row.Total)
if total < 0 {
total = 0
}
result[row.ProjectFlockKandangId] = int64(total)
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) { func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) {
if projectFlockKandangId == 0 { if projectFlockKandangId == 0 {
return 0, nil return 0, nil
@@ -430,34 +691,6 @@ func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang(
Scan(&result).Error Scan(&result).Error
return result, err 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) { 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. // Body-weight tracking is removed; keep stub for report compatibility.
return 0, 0, nil return 0, 0, nil
@@ -568,6 +801,82 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID
return result, nil return result, nil
} }
func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
idsSubquery := `
SELECT pfp.id
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
`
updateWithAlloc := `
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id
AND p.id IN (` + idsSubquery + `)
`
resetMissing := `
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE p.id IN (` + idsSubquery + `)
AND NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.stockable_id = p.id
)
`
db := r.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil {
return err
}
if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil {
return err
}
return nil
}
func (r *RecordingRepositoryImpl) ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) {
if len(ids) == 0 {
return 0, nil
}
var invalidIDs []uint
if err := r.DB().WithContext(ctx).
Table("product_warehouses pw").
Where("pw.id IN ?", ids).
Where(`NOT EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = pw.product_id
AND UPPER(f.name) IN ?
)`, flags).
Pluck("pw.id", &invalidIDs).Error; err != nil {
return 0, err
}
if len(invalidIDs) > 0 {
return invalidIDs[0], nil
}
return 0, nil
}
func nextRecordingDay(days []int) int { func nextRecordingDay(days []int) int {
if len(days) == 0 { if len(days) == 0 {
return 1 return 1
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
package validation package validation
import "time"
type ( type (
Stock struct { Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
@@ -35,6 +37,7 @@ type Update struct {
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Offset int `query:"-" validate:"omitempty,number,min=0"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
} }
@@ -44,3 +47,9 @@ type Approve struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
} }
type GetRecordingNextDay struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" query:"project_flock_kandang_id" validate:"required,number,min=1"`
RecordTime *string `json:"record_date" query:"record_date" validate:"required,datetime=2006-01-02"`
RecordTimeValue *time.Time `query:"-" validate:"-"`
}
@@ -465,6 +465,11 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
); err != nil { ); err != nil {
return err return err
} }
if strings.EqualFold(category, string(utils.ProjectFlockCategoryGrowing)) {
if err := s.updateGrowingFcrForWeek(tx, createBody.ProjectFlockKandangId, createBody.Week, calculation.MeanUp); err != nil {
return err
}
}
return nil return nil
}); err != nil { }); err != nil {
s.Log.Errorf("Failed to create uniformity: %+v", err) s.Log.Errorf("Failed to create uniformity: %+v", err)
@@ -633,6 +638,9 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
return nil, err return nil, err
} }
if err := s.updateGrowingFcrFromUniformity(c.Context(), id); err != nil {
return nil, err
}
return s.GetOne(c, id) return s.GetOne(c, id)
} }
@@ -694,6 +702,10 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
} }
} }
if err := s.updateGrowingFcrFromUniformity(c.Context(), id); err != nil {
return nil, err
}
return s.GetOne(c, id) return s.GetOne(c, id)
} }
@@ -724,7 +736,48 @@ func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error {
return err return err
} }
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { type uniformityContext struct {
ID uint
Week int
ProjectFlockKandangId uint
Category string
}
var ctxRow uniformityContext
if err := s.Repository.DB().WithContext(c.Context()).
Table("project_flock_kandang_uniformity u").
Select("u.id, u.week, u.project_flock_kandang_id, pf.category").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("u.id = ?", id).
Scan(&ctxRow).Error; err != nil {
return err
}
if ctxRow.ID == 0 {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
repoTx := s.Repository.WithTx(tx)
if err := repoTx.DeleteOne(c.Context(), id); err != nil {
return err
}
if strings.EqualFold(ctxRow.Category, string(utils.ProjectFlockCategoryGrowing)) {
startDay := (ctxRow.Week-1)*7 + 1
endDay := ctxRow.Week * 7
if ctxRow.Week > 0 {
if err := tx.Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", ctxRow.ProjectFlockKandangId).
Where("day BETWEEN ? AND ?", startDay, endDay).
Update("fcr_value", 0).Error; err != nil {
return err
}
}
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found") return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
} }
@@ -734,6 +787,58 @@ func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error {
return nil return nil
} }
func (s *uniformityService) updateGrowingFcrFromUniformity(ctx context.Context, uniformityID uint) error {
if uniformityID == 0 {
return nil
}
type uniformityRow struct {
ID uint
Week int
MeanUp float64
ProjectFlockKandangId uint
Category string
}
var row uniformityRow
if err := s.Repository.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity u").
Select("u.id, u.week, u.mean_up, u.project_flock_kandang_id, pf.category").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("u.id = ?", uniformityID).
Scan(&row).Error; err != nil {
return err
}
if row.ID == 0 {
return nil
}
if !strings.EqualFold(row.Category, string(utils.ProjectFlockCategoryGrowing)) {
return nil
}
return s.updateGrowingFcrForWeek(s.Repository.DB().WithContext(ctx), row.ProjectFlockKandangId, row.Week, row.MeanUp)
}
func (s *uniformityService) updateGrowingFcrForWeek(tx *gorm.DB, projectFlockKandangID uint, week int, meanUp float64) error {
if tx == nil || projectFlockKandangID == 0 || week <= 0 {
return nil
}
startDay := (week-1)*7 + 1
endDay := week * 7
meanBw := meanUp / 1.10
if meanBw <= 0 {
return tx.Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangID).
Where("day BETWEEN ? AND ?", startDay, endDay).
Update("fcr_value", 0).Error
}
return tx.Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangID).
Where("day BETWEEN ? AND ?", startDay, endDay).
Update("fcr_value", gorm.Expr("CASE WHEN feed_intake IS NULL OR feed_intake = 0 THEN 0 ELSE feed_intake / ? END", meanBw)).Error
}
func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) { func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
@@ -1,6 +1,7 @@
package dto package dto
import ( import (
"strings"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -27,6 +28,11 @@ type PurchaseListDTO struct {
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
DueDate *time.Time `json:"due_date"` DueDate *time.Time `json:"due_date"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
RequesterName string `json:"requester_name"`
PoExpedition []PoExpeditionDTO `json:"po_expedition"`
Products []productDTO.ProductRelationDTO `json:"products"`
Location *locationDTO.LocationRelationDTO `json:"location"`
Area *areaDTO.AreaRelationDTO `json:"area"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
@@ -61,6 +67,12 @@ type PurchaseItemDTO struct {
VehicleNumber *string `json:"vehicle_number"` VehicleNumber *string `json:"vehicle_number"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"` TransportPerItem *float64 `json:"transport_per_item,omitempty"`
ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"` ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"`
HasChickin bool `json:"has_chickin"`
}
type PoExpeditionDTO struct {
Id uint64 `json:"id"`
Refrence string `json:"refrence"`
} }
func ToPurchaseRelationDTO(p *entity.Purchase) PurchaseRelationDTO { func ToPurchaseRelationDTO(p *entity.Purchase) PurchaseRelationDTO {
@@ -89,6 +101,7 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO {
TravelNumber: item.TravelNumber, TravelNumber: item.TravelNumber,
TravelDocumentPath: item.TravelNumberDocs, TravelDocumentPath: item.TravelNumberDocs,
VehicleNumber: item.VehicleNumber, VehicleNumber: item.VehicleNumber,
HasChickin: item.HasChickin,
} }
if item.Product != nil && item.Product.Id != 0 { if item.Product != nil && item.Product.Id != 0 {
@@ -146,6 +159,10 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
mapped := userDTO.ToUserRelationDTO(p.CreatedUser) mapped := userDTO.ToUserRelationDTO(p.CreatedUser)
createdUser = &mapped createdUser = &mapped
} }
requesterName := ""
if createdUser != nil {
requesterName = createdUser.Name
}
var latestApproval *approvalDTO.ApprovalRelationDTO var latestApproval *approvalDTO.ApprovalRelationDTO
if p.LatestApproval != nil && p.LatestApproval.Id != 0 { if p.LatestApproval != nil && p.LatestApproval.Id != 0 {
@@ -153,11 +170,57 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
latestApproval = &mapped latestApproval = &mapped
} }
var (
poExpedition = make([]PoExpeditionDTO, 0)
location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO
)
productMap := make(map[uint]productDTO.ProductRelationDTO)
expeditionRefSet := make(map[uint64]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 {
exp := item.ExpenseNonstock.Expense
ref := strings.TrimSpace(exp.ReferenceNumber)
if exp.Id != 0 && ref != "" {
if _, exists := expeditionRefSet[exp.Id]; !exists {
expeditionRefSet[exp.Id] = struct{}{}
poExpedition = append(poExpedition, PoExpeditionDTO{
Id: exp.Id,
Refrence: 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{ return PurchaseListDTO{
PurchaseRelationDTO: ToPurchaseRelationDTO(&p), PurchaseRelationDTO: ToPurchaseRelationDTO(&p),
Supplier: supplier, Supplier: supplier,
DueDate: p.DueDate, DueDate: p.DueDate,
CreatedUser: createdUser, CreatedUser: createdUser,
RequesterName: requesterName,
PoExpedition: poExpedition,
Products: products,
Location: location,
Area: area,
CreatedAt: p.CreatedAt, CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt, UpdatedAt: p.UpdatedAt,
LatestApproval: latestApproval, LatestApproval: latestApproval,
@@ -255,6 +255,39 @@ func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error
if err := s.attachLatestApproval(c.Context(), purchase); err != nil { if err := s.attachLatestApproval(c.Context(), purchase); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err)
} }
if len(purchase.Items) > 0 {
itemIDs := make([]uint, 0, len(purchase.Items))
for i := range purchase.Items {
if purchase.Items[i].Id == 0 {
continue
}
itemIDs = append(itemIDs, purchase.Items[i].Id)
}
if len(itemIDs) > 0 {
var usedIDs []uint
if err := s.PurchaseRepo.DB().WithContext(c.Context()).
Model(&entity.StockAllocation{}).
Distinct("stockable_id").
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Pluck("stockable_id", &usedIDs).Error; err != nil {
return nil, err
}
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
}
for i := range purchase.Items {
if _, ok := usedSet[purchase.Items[i].Id]; ok {
purchase.Items[i].HasChickin = true
}
}
}
}
s.applyTravelDocumentURLs(c.Context(), purchase) s.applyTravelDocumentURLs(c.Context(), purchase)
return purchase, nil return purchase, nil
@@ -498,6 +531,54 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
return nil, utils.BadRequest("Items must not be empty for staff approval") return nil, utils.BadRequest("Items must not be empty for staff approval")
} }
if action == entity.ApprovalActionApproved {
itemIDs := make([]uint, 0, len(purchase.Items))
itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
if purchase.Items[i].Id == 0 {
continue
}
itemIDs = append(itemIDs, purchase.Items[i].Id)
itemByID[purchase.Items[i].Id] = purchase.Items[i]
}
if len(itemIDs) > 0 {
var usedIDs []uint
if err := s.PurchaseRepo.DB().WithContext(ctx).
Model(&entity.StockAllocation{}).
Distinct("stockable_id").
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Pluck("stockable_id", &usedIDs).Error; err != nil {
return nil, err
}
if len(usedIDs) > 0 {
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
}
for _, payload := range req.Items {
if payload.PurchaseItemID == 0 || payload.Qty == nil {
continue
}
if _, used := usedSet[payload.PurchaseItemID]; !used {
continue
}
item, ok := itemByID[payload.PurchaseItemID]
if !ok {
continue
}
if *payload.Qty != item.SubQty {
return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah")
}
}
}
}
}
payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving) payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -745,6 +826,54 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
req.Items[idx].TravelDocumentPath = &uploadedURL req.Items[idx].TravelDocumentPath = &uploadedURL
} }
} }
if action == entity.ApprovalActionApproved {
itemIDs := make([]uint, 0, len(purchase.Items))
itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
if purchase.Items[i].Id == 0 {
continue
}
itemIDs = append(itemIDs, purchase.Items[i].Id)
itemByID[purchase.Items[i].Id] = purchase.Items[i]
}
if len(itemIDs) > 0 {
var usedIDs []uint
if err := s.PurchaseRepo.DB().WithContext(ctx).
Model(&entity.StockAllocation{}).
Distinct("stockable_id").
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Pluck("stockable_id", &usedIDs).Error; err != nil {
return nil, err
}
if len(usedIDs) > 0 {
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
}
for _, payload := range req.Items {
if _, used := usedSet[payload.PurchaseItemID]; !used {
continue
}
item, ok := itemByID[payload.PurchaseItemID]
if !ok {
continue
}
receivedQty := item.SubQty
if payload.ReceivedQty != nil {
receivedQty = *payload.ReceivedQty
}
if receivedQty != item.TotalQty {
return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah")
}
}
}
}
}
itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items { for i := range purchase.Items {
@@ -912,6 +1041,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
pwID uint pwID uint
qty float64 qty float64
}, 0, len(prepared)) }, 0, len(prepared))
resolvePendingIDs := make(map[uint]struct{})
logEntries := make([]struct { logEntries := make([]struct {
itemID uint itemID uint
pwID uint pwID uint
@@ -934,6 +1064,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
if err != nil { if err != nil {
return err return err
} }
// Safety: ensure the PW we got matches the purchase item product.
if pwDetail, err := pwRepoTx.GetDetailByID(c.Context(), pwID); err != nil {
return err
} else if pwDetail.ProductId != uint(item.ProductId) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Product warehouse %d belongs to product %d, not purchase item product %d", pwID, pwDetail.ProductId, item.ProductId),
)
}
newPWID = &pwID newPWID = &pwID
deltaQty := prep.receivedQty - item.TotalQty deltaQty := prep.receivedQty - item.TotalQty
@@ -952,6 +1091,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
pwID uint pwID uint
qty float64 qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) }{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
resolvePendingIDs[*newPWID] = struct{}{}
} else { } else {
deltas[*newPWID] += deltaQty deltas[*newPWID] += deltaQty
totalQtyDeltas[item.Id] += deltaQty totalQtyDeltas[item.Id] += deltaQty
@@ -964,11 +1104,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
qty float64 qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) }{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
affected[*newPWID] = struct{}{} affected[*newPWID] = struct{}{}
resolvePendingIDs[*newPWID] = struct{}{}
} else { } else {
deltas[*newPWID] += deltaQty // negative deltas[*newPWID] += deltaQty // negative
affected[*newPWID] = struct{}{} affected[*newPWID] = struct{}{}
totalQtyDeltas[item.Id] += deltaQty totalQtyDeltas[item.Id] += deltaQty
} }
case newPWID != nil:
resolvePendingIDs[*newPWID] = struct{}{}
} }
dateCopy := prep.receivedDate dateCopy := prep.receivedDate
@@ -1066,6 +1209,19 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return err return err
} }
} }
for pwID := range resolvePendingIDs {
if pwID == 0 {
continue
}
resolved, err := s.FifoSvc.ResolvePending(c.Context(), commonSvc.PendingResolveRequest{
ProductWarehouseID: pwID,
Tx: tx,
})
if err != nil {
return err
}
s.Log.Infof("ResolvePending purchase=%d pw=%d resolved=%d", purchase.Id, pwID, len(resolved))
}
} }
if len(logEntries) > 0 { if len(logEntries) > 0 {
@@ -1349,6 +1505,30 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
} }
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
itemIDs := make([]uint, 0, len(itemsToDelete))
for _, item := range itemsToDelete {
if item.Id == 0 {
continue
}
itemIDs = append(itemIDs, item.Id)
}
if len(itemIDs) > 0 {
var count int64
if err := tx.Model(&entity.StockAllocation{}).
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return utils.BadRequest("Purchase already chickin, failed to delete purchase")
}
}
if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil { if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil {
return err return err
} }
@@ -1365,6 +1545,10 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
return nil return nil
}) })
if transactionErr != nil { if transactionErr != nil {
var fe *fiber.Error
if errors.As(transactionErr, &fe) {
return fe
}
if errors.Is(transactionErr, gorm.ErrRecordNotFound) { if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
return utils.NotFound("Purchase not found") return utils.NotFound("Purchase not found")
} }
@@ -51,7 +51,7 @@ type ReceivePurchaseItemRequest struct {
type ReceivePurchaseRequest struct { type ReceivePurchaseRequest struct {
Action string `form:"action" json:"action" validate:"required,oneof=APPROVED REJECTED"` 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"` TravelDocuments []*multipart.FileHeader `form:"travel_documents" json:"-" validate:"omitempty,dive"`
Notes *string `form:"notes" json:"notes,omitempty" validate:"omitempty,max=500"` 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), Page: ctx.QueryInt("page", 1),
Limit: ctx.QueryInt("limit", 10), Limit: ctx.QueryInt("limit", 10),
CustomerIDs: customerIDs, CustomerIDs: customerIDs,
FilterBy: strings.ToUpper(ctx.Query("filter_by", "")),
StartDate: ctx.Query("start_date", ""), StartDate: ctx.Query("start_date", ""),
EndDate: ctx.Query("end_date", ""), EndDate: ctx.Query("end_date", ""),
} }
@@ -6,6 +6,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm" "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 return rows, docSuppliers, nil
} }
@@ -31,9 +31,25 @@ func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang(
return []entity.Recording{}, 0, nil 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). countQuery := r.db.WithContext(ctx).
Model(&entity.Recording{}). 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 var total int64
if err := countQuery.Count(&total).Error; err != nil { if err := countQuery.Count(&total).Error; err != nil {
@@ -59,7 +75,10 @@ func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang(
dataQuery := r.db.WithContext(ctx). dataQuery := r.db.WithContext(ctx).
Model(&entity.Recording{}). Model(&entity.Recording{}).
Joins("JOIN (?) AS la ON la.approvable_id = recordings.id", latestApproval).
Where("project_flock_kandangs_id = ?", projectFlockKandangID). 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 { Preload("Eggs", func(db *gorm.DB) *gorm.DB {
return db.Select("recording_eggs.*, f.name AS product_flag_name"). 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"). 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 { if detail != nil && detail.TargetMeanBw != nil {
weeklyResults[i].StdBw = *detail.TargetMeanBw 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 return dto.CustomerPaymentReportItem{}, err
} }
filterBy := strings.ToUpper(strings.TrimSpace(params.FilterBy))
if filterBy == "" {
filterBy = utils.CustomerPaymentFilterByTransDate
}
var startDate, endDate *time.Time var startDate, endDate *time.Time
if params.StartDate != "" { if params.StartDate != "" {
parsed, err := time.ParseInLocation("2006-01-02", params.StartDate, location) 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 { for _, row := range rows {
transDate := row.TransDate.In(location) var compareDate time.Time
if startDate != nil && transDate.Before(*startDate) { 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 continue
} }
if endDate != nil && transDate.After(*endDate) { if endDate != nil && compareDate.After(*endDate) {
continue continue
} }
filteredRows = append(filteredRows, row) filteredRows = append(filteredRows, row)
@@ -1352,10 +1369,12 @@ func buildDebtSupplierRow(purchase entity.Purchase, now time.Time, loc *time.Loc
poNumber = *purchase.PoNumber poNumber = *purchase.PoNumber
} }
prDate := purchase.CreatedAt.In(loc) startDate := resolveDebtSupplierReceivedDate(purchase, loc)
startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc)
endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, 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 totalPrice := 0.0
travelNumber := "-" travelNumber := "-"
@@ -1525,8 +1544,10 @@ func isDebtSupplierPaid(totalPrice, paymentTotal float64) bool {
} }
func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc *time.Location) int { func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc *time.Location) int {
prDate := purchase.CreatedAt.In(loc) startDate := resolveDebtSupplierReceivedDate(purchase, loc)
startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc) if startDate.IsZero() {
return 0
}
stopDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc) stopDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc)
if stopDate.Before(startDate) { if stopDate.Before(startDate) {
return 0 return 0
@@ -1534,6 +1555,23 @@ func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc
return int(stopDate.Sub(startDate).Hours() / 24) 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) { func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) {
params, filters, err := s.parseHppPerKandangQuery(ctx) params, filters, err := s.parseHppPerKandangQuery(ctx)
if err != nil { if err != nil {
@@ -85,6 +85,7 @@ type CustomerPaymentQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"` Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"`
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,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"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
} }
@@ -196,7 +196,11 @@ func (h *Controller) Refresh(c *fiber.Ctx) error {
verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) verification, err := sso.VerifyAccessToken(tokenResp.AccessToken)
if err != nil { if err != nil {
utils.Log.Errorf("access token verification failed: %v", err) if sso.IsSignatureError(err) {
logSignatureError("sso refresh", "sso_token", tokenResp.AccessToken, err)
} else {
utils.Log.Errorf("access token verification failed: %v", err)
}
return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") return fiber.NewError(fiber.StatusUnauthorized, "invalid access token")
} }
@@ -304,7 +308,11 @@ func (h *Controller) Callback(c *fiber.Ctx) error {
verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) verification, err := sso.VerifyAccessToken(tokenResp.AccessToken)
if err != nil { if err != nil {
utils.Log.Errorf("access token verification failed: %v", err) if sso.IsSignatureError(err) {
logSignatureError("sso callback", "sso_token", tokenResp.AccessToken, err)
} else {
utils.Log.Errorf("access token verification failed: %v", err)
}
return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") return fiber.NewError(fiber.StatusUnauthorized, "invalid access token")
} }
@@ -337,6 +345,22 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
token := strings.TrimSpace(c.Cookies(accessName)) token := strings.TrimSpace(c.Cookies(accessName))
tokenFromCookie := token != "" tokenFromCookie := token != ""
usedCookieName := accessName
if !tokenFromCookie {
for _, name := range config.SSOAccessCookieFallback {
name = strings.TrimSpace(name)
if name == "" || name == accessName {
continue
}
token = strings.TrimSpace(c.Cookies(name))
if token != "" {
tokenFromCookie = true
usedCookieName = name
break
}
}
}
if !tokenFromCookie { if !tokenFromCookie {
authHeader := strings.TrimSpace(c.Get("Authorization")) authHeader := strings.TrimSpace(c.Get("Authorization"))
@@ -363,7 +387,11 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
} }
if _, err := sso.VerifyAccessToken(token); err != nil { if _, err := sso.VerifyAccessToken(token); err != nil {
utils.Log.WithError(err).Warn("access token verification failed for userinfo") if sso.IsSignatureError(err) {
logSignatureError("sso userinfo", "request", token, err)
} else {
utils.Log.WithError(err).Warn("access token verification failed for userinfo")
}
return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated")
} }
@@ -382,7 +410,7 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
// SSO /auth/get-me expects the access cookie; add Authorization as well for compatibility. // SSO /auth/get-me expects the access cookie; add Authorization as well for compatibility.
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
if tokenFromCookie { if tokenFromCookie {
req.Header.Set("Cookie", fmt.Sprintf("%s=%s", accessName, token)) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", usedCookieName, token))
} }
resp, err := h.httpClient.Do(req) resp, err := h.httpClient.Do(req)
@@ -400,13 +428,6 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response") return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response")
} }
// if sanitized, perms, ok := sanitizeUserInfoPayload(body); ok {
// if caps := capabilities.FromPermissions(perms); len(caps) > 0 {
// injectCapabilities(sanitized, caps)
// }
// return c.Status(resp.StatusCode).JSON(sanitized)
// }
if ct := resp.Header.Get("Content-Type"); ct != "" { if ct := resp.Header.Get("Content-Type"); ct != "" {
c.Set("Content-Type", ct) c.Set("Content-Type", ct)
} else { } else {
@@ -418,17 +439,9 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
// Logout clears SSO cookies and removes any leftover PKCE session state. // Logout clears SSO cookies and removes any leftover PKCE session state.
func (h *Controller) Logout(c *fiber.Ctx) error { func (h *Controller) Logout(c *fiber.Ctx) error {
requestedAlias := normalizeClientParam(c.Query("client")) alias := ""
if requestedAlias == "" { if singleAlias, _, ok := singleSSOClient(); ok {
requestedAlias = normalizeClientParam(c.Query("client_id")) alias = singleAlias
}
var (
alias string
cfg config.SSOClientConfig
hasClientInfo bool
)
if requestedAlias != "" {
alias, cfg, hasClientInfo = findSSOClientConfig(requestedAlias)
} }
accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access") accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access")
@@ -445,14 +458,7 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
hadAccessCookie := accessToken != "" hadAccessCookie := accessToken != ""
hadRefreshCookie := refreshToken != "" hadRefreshCookie := refreshToken != ""
state := strings.TrimSpace(c.Query("state")) if !hadAccessCookie && !hadRefreshCookie {
if state != "" {
if err := h.store.Delete(c.Context(), state); err != nil {
utils.Log.Warnf("failed to delete pkce session during logout: %v", err)
}
}
if !hadAccessCookie && !hadRefreshCookie && state == "" {
return fiber.NewError(fiber.StatusUnauthorized, "not authenticated") return fiber.NewError(fiber.StatusUnauthorized, "not authenticated")
} }
@@ -477,52 +483,20 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
clearSSOCookie(c, refreshName) clearSSOCookie(c, refreshName)
redirectTarget := "" redirectTarget := ""
rawReturn := strings.TrimSpace(c.Query("return_to")) if config.SSOPortalURL != "" {
if hasClientInfo { redirectTarget = config.SSOPortalURL
if rawReturn == "" {
rawReturn = cfg.DefaultReturnURI
}
if normalized, err := normalizeReturnTarget(rawReturn, cfg); err == nil {
redirectTarget = normalized
} else if rawReturn != "" {
utils.Log.WithError(err).Warn("invalid return_to during logout")
}
} else if rawReturn == "" && config.SSOPortalURL != "" {
if alias, singleCfg, ok := singleClientFromToken(verification); ok {
if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" {
redirectTarget = normalized
alias, cfg, hasClientInfo = alias, singleCfg, true
} else {
redirectTarget = config.SSOPortalURL
}
} else if accessToken != "" {
if alias, singleCfg, ok := h.singleClientFromSSO(c.Context(), accessToken); ok {
if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" {
redirectTarget = normalized
alias, cfg, hasClientInfo = alias, singleCfg, true
} else {
redirectTarget = config.SSOPortalURL
}
} else {
redirectTarget = config.SSOPortalURL
}
} else {
redirectTarget = config.SSOPortalURL
}
} else if rawReturn != "" {
if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") {
redirectTarget = rawReturn
}
} }
utils.Log.WithFields(logrus.Fields{ utils.Log.WithFields(logrus.Fields{
"client": alias, "client": alias,
"state": state,
"redirect": redirectTarget, "redirect": redirectTarget,
}).Info("sso logout completed") }).Info("sso logout completed")
if redirectTarget != "" { if redirectTarget != "" {
return c.Redirect(redirectTarget, fiber.StatusFound) return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "signed out",
"redirect": redirectTarget,
})
} }
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"}) return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"})
@@ -541,145 +515,6 @@ func singleSSOClient() (string, config.SSOClientConfig, bool) {
return "", config.SSOClientConfig{}, false return "", config.SSOClientConfig{}, false
} }
func singleClientFromToken(verification *sso.VerificationResult) (string, config.SSOClientConfig, bool) {
if verification == nil || verification.Claims == nil {
return "", config.SSOClientConfig{}, false
}
return singleClientFromScopes(verification.Claims.Scopes())
}
func (h *Controller) singleClientFromSSO(ctx context.Context, accessToken string) (string, config.SSOClientConfig, bool) {
accessToken = strings.TrimSpace(accessToken)
if accessToken == "" {
return "", config.SSOClientConfig{}, false
}
meURL := strings.TrimSpace(config.SSOGetMeURL)
if meURL == "" {
return "", config.SSOClientConfig{}, false
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
if err != nil {
utils.Log.WithError(err).Warn("failed to build SSO getme request")
return "", config.SSOClientConfig{}, false
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := h.httpClient.Do(req)
if err != nil {
utils.Log.WithError(err).Warn("SSO getme request failed")
return "", config.SSOClientConfig{}, false
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
utils.Log.WithField("status", resp.StatusCode).Warn("SSO getme responded with error")
return "", config.SSOClientConfig{}, false
}
var payload struct {
Data struct {
Roles []struct {
Client *struct {
Alias string `json:"alias"`
} `json:"client"`
} `json:"roles"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
utils.Log.WithError(err).Warn("failed to decode SSO getme response")
return "", config.SSOClientConfig{}, false
}
aliases := make(map[string]struct{})
for _, role := range payload.Data.Roles {
if role.Client == nil {
continue
}
alias := strings.ToLower(strings.TrimSpace(role.Client.Alias))
if alias != "" {
aliases[alias] = struct{}{}
}
}
if len(aliases) != 1 {
return "", config.SSOClientConfig{}, false
}
for alias := range aliases {
if normalized, cfg, ok := findClientAlias(alias); ok {
return normalized, cfg, true
}
return "", config.SSOClientConfig{}, false
}
return "", config.SSOClientConfig{}, false
}
func singleClientFromScopes(scopes []string) (string, config.SSOClientConfig, bool) {
if len(scopes) == 0 {
return "", config.SSOClientConfig{}, false
}
seen := make(map[string]struct{})
for _, scope := range scopes {
if alias, ok := matchClientAliasFromScope(scope); ok {
seen[alias] = struct{}{}
}
if len(seen) > 1 {
return "", config.SSOClientConfig{}, false
}
}
if len(seen) != 1 {
return "", config.SSOClientConfig{}, false
}
for alias := range seen {
if normalized, cfg, ok := findClientAlias(alias); ok {
return normalized, cfg, true
}
}
return "", config.SSOClientConfig{}, false
}
func matchClientAliasFromScope(scope string) (string, bool) {
scope = strings.ToLower(strings.TrimSpace(scope))
if scope == "" {
return "", false
}
prefix := scope
if idx := strings.IndexAny(prefix, ".:"); idx > 0 {
prefix = prefix[:idx]
}
if prefix == "" {
return "", false
}
if alias, _, ok := findClientAlias(prefix); ok {
return alias, true
}
if prefix == "user-management" {
if alias, _, ok := findClientAlias("umgmt"); ok {
return alias, true
}
}
if prefix == "umgmt" {
if alias, _, ok := findClientAlias("user-management"); ok {
return alias, true
}
}
return "", false
}
func findClientAlias(alias string) (string, config.SSOClientConfig, bool) {
alias = strings.TrimSpace(alias)
if alias == "" {
return "", config.SSOClientConfig{}, false
}
if cfg, ok := config.SSOClients[alias]; ok && strings.TrimSpace(cfg.PublicID) != "" {
return alias, cfg, true
}
for key, cfg := range config.SSOClients {
if strings.EqualFold(key, alias) && strings.TrimSpace(cfg.PublicID) != "" {
return key, cfg, true
}
}
return "", config.SSOClientConfig{}, false
}
func defaultSSOClientAlias() string { func defaultSSOClientAlias() string {
for alias := range config.SSOClients { for alias := range config.SSOClients {
@@ -836,6 +671,27 @@ func resolveSSOCookieName(configuredName, fallback string) string {
return strings.TrimSpace(fallback) return strings.TrimSpace(fallback)
} }
func logSignatureError(ctxLabel, tokenSource, token string, err error) {
info := sso.ExtractTokenInfo(token)
aud := strings.Join(info.Aud, ",")
utils.Log.Errorf(
"access token verification failed: %v | ctx=%s source=%s iss=%s kid=%s aud=%s sub=%s exp=%d iat=%d nbf=%d expected_iss=%s expected_aud=%v jwks=%s",
err,
ctxLabel,
tokenSource,
info.Iss,
info.Kid,
aud,
info.Sub,
info.Exp,
info.Iat,
info.Nbf,
config.SSOIssuer,
config.SSOAllowedAudiences,
config.SSOJWKSURL,
)
}
func normalizeClientParam(raw string) string { func normalizeClientParam(raw string) string {
value := strings.TrimSpace(raw) value := strings.TrimSpace(raw)
if value == "" { if value == "" {
@@ -848,98 +704,6 @@ func normalizeClientParam(raw string) string {
return strings.ToLower(value) return strings.ToLower(value)
} }
func sanitizeUserInfoPayload(body []byte) (map[string]any, []string, bool) {
if len(body) == 0 {
return map[string]any{}, nil, true
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, nil, false
}
perms := collectPermissionNames(payload)
sensitive := map[string]struct{}{
"roles": {},
"permissions": {},
}
payload = scrubSensitiveKeys(payload, sensitive)
sanitized, ok := payload.(map[string]any)
if !ok {
sanitized = map[string]any{"data": payload}
}
return sanitized, perms, true
}
func scrubSensitiveKeys(value any, sensitive map[string]struct{}) any {
switch v := value.(type) {
case map[string]any:
for key, val := range v {
if _, ok := sensitive[strings.ToLower(key)]; ok {
delete(v, key)
continue
}
v[key] = scrubSensitiveKeys(val, sensitive)
}
return v
case []any:
for i, item := range v {
v[i] = scrubSensitiveKeys(item, sensitive)
}
return v
default:
return value
}
}
func collectPermissionNames(value any) []string {
names := make(map[string]struct{})
collectPermissionRec(value, names)
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
return out
}
func collectPermissionRec(value any, acc map[string]struct{}) {
switch v := value.(type) {
case map[string]any:
for key, val := range v {
if strings.EqualFold(key, "permissions") {
if arr, ok := val.([]any); ok {
for _, item := range arr {
if perm, ok := item.(map[string]any); ok {
if name, ok := perm["name"].(string); ok && strings.TrimSpace(name) != "" {
acc[strings.ToLower(strings.TrimSpace(name))] = struct{}{}
}
}
}
}
} else {
collectPermissionRec(val, acc)
}
}
case []any:
for _, item := range v {
collectPermissionRec(item, acc)
}
}
}
func injectCapabilities(payload map[string]any, caps map[string]bool) {
if len(caps) == 0 {
return
}
if data, ok := payload["data"].(map[string]any); ok {
data["capabilities"] = caps
return
}
payload["capabilities"] = caps
}
func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) { func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) {
if requestedAlias == "" { if requestedAlias == "" {
+189 -28
View File
@@ -2,9 +2,11 @@ package sso
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -17,9 +19,10 @@ import (
) )
type verifier struct { type verifier struct {
jwks *keyfunc.JWKS jwks *keyfunc.JWKS
issuer string issuer string
audiences map[string]struct{} audiences map[string]struct{}
hmacSecret []byte
} }
type AccessTokenClaims struct { type AccessTokenClaims struct {
@@ -41,18 +44,54 @@ type VerificationResult struct {
Claims *AccessTokenClaims Claims *AccessTokenClaims
} }
type TokenInfo struct {
Kid string
Iss string
Aud []string
Sub string
Exp int64
Iat int64
Nbf int64
}
var ( var (
globalMu sync.RWMutex globalMu sync.RWMutex
globalV *verifier globalV *verifier
) )
func Init(ctx context.Context, jwksURL, issuer string, audiences []string) error { func Init(ctx context.Context, jwksURL, issuer string, audiences []string, hmacSecret string) error {
jwksURL = strings.TrimSpace(jwksURL) jwksURL = strings.TrimSpace(jwksURL)
issuer = strings.TrimSpace(issuer) issuer = strings.TrimSpace(issuer)
if jwksURL == "" || issuer == "" { hmacSecret = strings.TrimSpace(hmacSecret)
return errors.New("missing SSO JWKS or issuer configuration") if issuer == "" {
return errors.New("missing SSO issuer configuration")
} }
audienceMap := make(map[string]struct{}, len(audiences))
for _, aud := range audiences {
aud = strings.TrimSpace(aud)
if aud == "" {
continue
}
audienceMap[aud] = struct{}{}
}
globalMu.Lock()
if hmacSecret != "" {
globalV = &verifier{
jwks: nil,
issuer: issuer,
audiences: audienceMap,
hmacSecret: []byte(hmacSecret),
}
globalMu.Unlock()
utils.Log.Infof("sso verifier initialized for issuer %s (hmac)", issuer)
return nil
}
if jwksURL == "" {
globalMu.Unlock()
return errors.New("missing SSO JWKS configuration")
}
client := &http.Client{Timeout: 5 * time.Second} client := &http.Client{Timeout: 5 * time.Second}
options := keyfunc.Options{ options := keyfunc.Options{
Ctx: ctx, Ctx: ctx,
@@ -67,19 +106,9 @@ func Init(ctx context.Context, jwksURL, issuer string, audiences []string) error
jwks, err := keyfunc.Get(jwksURL, options) jwks, err := keyfunc.Get(jwksURL, options)
if err != nil { if err != nil {
globalMu.Unlock()
return fmt.Errorf("load jwks: %w", err) return fmt.Errorf("load jwks: %w", err)
} }
audienceMap := make(map[string]struct{}, len(audiences))
for _, aud := range audiences {
aud = strings.TrimSpace(aud)
if aud == "" {
continue
}
audienceMap[aud] = struct{}{}
}
globalMu.Lock()
globalV = &verifier{jwks: jwks, issuer: issuer, audiences: audienceMap} globalV = &verifier{jwks: jwks, issuer: issuer, audiences: audienceMap}
globalMu.Unlock() globalMu.Unlock()
@@ -101,18 +130,47 @@ func VerifyAccessToken(token string) (*VerificationResult, error) {
} }
claims := &AccessTokenClaims{} claims := &AccessTokenClaims{}
parser := jwt.NewParser( if len(v.hmacSecret) > 0 {
jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), parser := jwt.NewParser(
jwt.WithIssuedAt(), jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithExpirationRequired(), jwt.WithIssuedAt(),
) jwt.WithExpirationRequired(),
)
tok, err := parser.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid token signing method")
}
return v.hmacSecret, nil
})
if err != nil {
return nil, fmt.Errorf("parse token: %w", err)
}
if !tok.Valid {
return nil, errors.New("invalid token")
}
} else {
parser := jwt.NewParser(
jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}),
jwt.WithIssuedAt(),
jwt.WithExpirationRequired(),
)
tok, err := parser.ParseWithClaims(token, claims, v.jwks.Keyfunc) tok, err := parser.ParseWithClaims(token, claims, v.jwks.Keyfunc)
if err != nil { if err != nil {
return nil, fmt.Errorf("parse token: %w", err) if shouldRefreshOnVerifyError(err) {
} if refreshErr := v.jwks.Refresh(context.Background(), keyfunc.RefreshOptions{IgnoreRateLimit: true}); refreshErr != nil {
if !tok.Valid { utils.Log.WithError(refreshErr).Warn("sso jwks refresh after signature error failed")
return nil, errors.New("invalid token") } else {
tok, err = parser.ParseWithClaims(token, claims, v.jwks.Keyfunc)
}
}
if err != nil {
return nil, fmt.Errorf("parse token: %w", err)
}
}
if !tok.Valid {
return nil, errors.New("invalid token")
}
} }
if claims.Issuer != v.issuer { if claims.Issuer != v.issuer {
@@ -158,3 +216,106 @@ func VerifyAccessToken(token string) (*VerificationResult, error) {
return result, nil return result, nil
} }
func shouldRefreshOnVerifyError(err error) bool {
if !IsSignatureError(err) {
return false
}
return !disableRefreshOnSignatureError()
}
func IsSignatureError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "verification error") || strings.Contains(msg, "token signature is invalid")
}
func disableRefreshOnSignatureError() bool {
val := strings.TrimSpace(os.Getenv("SSO_DISABLE_JWKS_REFRESH_ON_SIG_ERROR"))
if val == "" {
return false
}
return val == "1" || strings.EqualFold(val, "true") || strings.EqualFold(val, "yes")
}
func ExtractTokenInfo(token string) TokenInfo {
token = strings.TrimSpace(token)
if token == "" {
return TokenInfo{}
}
claims := jwt.MapClaims{}
parser := jwt.NewParser()
tok, _, err := parser.ParseUnverified(token, claims)
if err != nil {
return TokenInfo{}
}
info := TokenInfo{}
if kid, ok := tok.Header["kid"].(string); ok {
info.Kid = kid
}
if iss, ok := claims["iss"].(string); ok {
info.Iss = iss
}
if sub, ok := claims["sub"].(string); ok {
info.Sub = sub
}
if aud, ok := claims["aud"]; ok {
info.Aud = toStringSlice(aud)
}
info.Exp = toInt64(claims["exp"])
info.Iat = toInt64(claims["iat"])
info.Nbf = toInt64(claims["nbf"])
return info
}
func toStringSlice(v any) []string {
switch t := v.(type) {
case string:
if t == "" {
return nil
}
return []string{t}
case []string:
out := make([]string, 0, len(t))
for _, s := range t {
if s != "" {
out = append(out, s)
}
}
return out
case []any:
out := make([]string, 0, len(t))
for _, item := range t {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func toInt64(v any) int64 {
switch t := v.(type) {
case int64:
return t
case int:
return int64(t)
case float64:
return int64(t)
case json.Number:
if n, err := t.Int64(); err == nil {
return n
}
case string:
if n, err := strconv.ParseInt(t, 10, 64); err == nil {
return n
}
}
return 0
}
@@ -0,0 +1,42 @@
package sso
import (
"context"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestVerifyAccessTokenHMAC(t *testing.T) {
secret := "test-secret-123"
issuer := "http://localhost:8080"
aud := []string{"client:1"}
if err := Init(context.Background(), "", issuer, aud, secret); err != nil {
t.Fatalf("Init error: %v", err)
}
claims := &AccessTokenClaims{
Scope: "openid profile",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: "user:1",
Audience: jwt.ClaimStrings(aud),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
if err != nil {
t.Fatalf("sign token error: %v", err)
}
result, err := VerifyAccessToken(token)
if err != nil {
t.Fatalf("VerifyAccessToken error: %v", err)
}
if result.UserID != 1 {
t.Fatalf("unexpected user id: %d", result.UserID)
}
}
+54 -3
View File
@@ -161,6 +161,15 @@ const (
ExpenseCategoryNonBOP ExpenseCategory = "NON-BOP" ExpenseCategoryNonBOP ExpenseCategory = "NON-BOP"
) )
// -------------------------------------------------------------------
// Filter Customer Payment
// -------------------------------------------------------------------
const (
CustomerPaymentFilterByTransDate = "TRANS_DATE"
CustomerPaymentFilterByRealizationDate = "REALIZATION_DATE"
)
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Payment Method // Payment Method
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -212,6 +221,31 @@ const (
KandangStatusActive KandangStatus = "ACTIVE" KandangStatusActive KandangStatus = "ACTIVE"
) )
// -------------------------------------------------------------------
// Marketing Type
// -------------------------------------------------------------------
type MarketingType string
const (
MarketingTypeAyam MarketingType = "AYAM"
MarketingTypeTelur MarketingType = "TELUR"
MarketingTypeTrading MarketingType = "TRADING"
MarketingTypeAyamPullet MarketingType = "AYAM_PULLET"
)
// -------------------------------------------------------------------
// Convertion Unit
// -------------------------------------------------------------------
type ConvertionUnit string
const (
ConvertionUnitPeti ConvertionUnit = "PETI"
ConvertionUnitKG ConvertionUnit = "KG"
ConvertionUnitQty ConvertionUnit = "QTY"
)
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// ProjectFlockCategory // ProjectFlockCategory
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -329,9 +363,10 @@ const (
PurchaseStepReceiving approvalutils.ApprovalStep = 4 PurchaseStepReceiving approvalutils.ApprovalStep = 4
PurchaseStepCompleted approvalutils.ApprovalStep = 5 PurchaseStepCompleted approvalutils.ApprovalStep = 5
PurchasePRNumberPrefix = "PR-LTI-" PurchasePRNumberPrefix = "PR-LTI-"
PurchasePONumberPrefix = "PO-LTI-" PurchasePONumberPrefix = "PO-LTI-"
PurchaseNumberPadding = 4 AdjustmentStockNumberPrefix = "ADJ-"
PurchaseNumberPadding = 4
) )
var PurchaseApprovalSteps = map[approvalutils.ApprovalStep]string{ var PurchaseApprovalSteps = map[approvalutils.ApprovalStep]string{
@@ -609,6 +644,22 @@ func IsValidPaymentParty(v string) bool {
return false return false
} }
func IsValidMarketingType(v string) bool {
switch MarketingType(v) {
case MarketingTypeAyam, MarketingTypeTelur, MarketingTypeTrading, MarketingTypeAyamPullet:
return true
}
return false
}
func IsValidConvertionUnit(v string) bool {
switch ConvertionUnit(v) {
case ConvertionUnitPeti, ConvertionUnitKG, ConvertionUnitQty:
return true
}
return false
}
// example use // example use
// Recording helper // Recording helper
+1
View File
@@ -17,4 +17,5 @@ const (
StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS"
StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION"
StockableKeyRecordingEgg StockableKey = "RECORDING_EGG" StockableKeyRecordingEgg StockableKey = "RECORDING_EGG"
StockableKeyRecordingDepletion StockableKey = "RECORDING_DEPLETION_IN"
) )
@@ -0,0 +1,317 @@
package recording
import (
"context"
"fmt"
"strings"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
type warnLogger interface {
Warnf(format string, args ...any)
}
type productWarehouseExistsRepo interface {
ExistsByID(ctx context.Context, id uint) (bool, error)
}
type recordingValidationRepo interface {
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
}
func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error {
if repo == nil || len(ids) == 0 {
return nil
}
for _, id := range ids {
ok, err := repo.ExistsByID(ctx, id)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("product warehouse %d not found", id)
}
}
return nil
}
func EnsureProductWarehousesByFlags(ctx context.Context, repo recordingValidationRepo, ids []uint, flags []string, label string) error {
if repo == nil || len(ids) == 0 {
return nil
}
invalidID, err := repo.ValidateProductWarehousesByFlags(ctx, ids, flags)
if err != nil {
return err
}
if invalidID != 0 {
return fmt.Errorf("product warehouse %d is not a %s warehouse", invalidID, label)
}
return nil
}
type idGetter[T any] func(T) uint
func CollectWarehouseIDs[T any](items []T, getID idGetter[T]) []uint {
if len(items) == 0 {
return nil
}
ids := make([]uint, 0, len(items))
for _, item := range items {
if id := getID(item); id != 0 {
ids = append(ids, id)
}
}
return ids
}
func EnsureProductWarehousesByFlagsForItems[T any](
ctx context.Context,
repo recordingValidationRepo,
items []T,
getID idGetter[T],
flags []string,
label string,
) error {
ids := CollectWarehouseIDs(items, getID)
return EnsureProductWarehousesByFlags(ctx, repo, ids, flags, label)
}
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 {
base = float64(totalChick) + currentDepletion
}
if base <= 0 {
return 0
}
return (currentDepletion / base) * 100
}
func AttachLatestApprovals(ctx context.Context, items []entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error {
if len(items) == 0 || approvalSvc == nil {
return nil
}
ids := make([]uint, 0, len(items))
visited := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.Id == 0 {
continue
}
if _, ok := visited[item.Id]; ok {
continue
}
visited[item.Id] = struct{}{}
ids = append(ids, item.Id)
}
if len(ids) == 0 {
return nil
}
latestMap, err := approvalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
if logger != nil {
logger.Warnf("Unable to load latest approvals for recordings: %+v", err)
}
return nil
}
if len(latestMap) == 0 {
return nil
}
for i := range items {
if items[i].Id == 0 {
continue
}
if approval, ok := latestMap[items[i].Id]; ok {
items[i].LatestApproval = approval
}
}
return nil
}
func AttachLatestApproval(ctx context.Context, item *entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error {
if item == nil || item.Id == 0 || approvalSvc == nil {
return nil
}
latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
if logger != nil {
logger.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err)
}
return nil
}
item.LatestApproval = latest
return nil
}
type productionStandardValues struct {
HenDay *float64
HenHouse *float64
FeedIntake *float64
MaxDepletion *float64
EggMass *float64
EggWeight *float64
}
func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, logger warnLogger, items ...*entity.Recording) error {
if len(items) == 0 {
return nil
}
type standardKey struct {
standardID uint
week int
}
type standardCacheEntry struct {
values productionStandardValues
fcr *float64
}
if db == nil {
return nil
}
standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db)
cache := make(map[standardKey]standardCacheEntry, len(items))
standardIDs := make(map[uint]struct{}, len(items))
for _, item := range items {
if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 {
continue
}
if item.ProjectFlockKandang.ProjectFlock.ProductionStandardId > 0 {
standardIDs[item.ProjectFlockKandang.ProjectFlock.ProductionStandardId] = struct{}{}
}
}
standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs))
growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs))
for standardID := range standardIDs {
details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
if warnOnly {
if logger != nil {
logger.Warnf("Unable to preload production standard detail for standard %d: %+v", standardID, err)
}
} else {
return err
}
continue
}
detailMap := make(map[int]*entity.ProductionStandardDetail, len(details))
for i := range details {
detail := details[i]
detailMap[detail.Week] = &detail
}
standardDetailByStd[standardID] = detailMap
growths, err := growthDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
if warnOnly {
if logger != nil {
logger.Warnf("Unable to preload standard growth detail for standard %d: %+v", standardID, err)
}
} else {
return err
}
continue
}
growthMap := make(map[int]*entity.StandardGrowthDetail, len(growths))
for i := range growths {
growth := growths[i]
growthMap[growth.Week] = &growth
}
growthDetailByStd[standardID] = growthMap
}
for _, item := range items {
if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 {
continue
}
standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId
if standardID == 0 {
continue
}
week := RecordingWeekValue(*item)
cacheKey := standardKey{standardID: standardID, week: week}
if cached, ok := cache[cacheKey]; ok {
applyProductionStandardValues(item, cached.values, cached.fcr)
continue
}
values := productionStandardValues{}
var fcr *float64
if detailMap, ok := standardDetailByStd[standardID]; ok {
if detail, ok := detailMap[week]; ok {
values.HenDay = detail.TargetHenDayProduction
values.HenHouse = detail.TargetHenHouseProduction
values.EggMass = detail.TargetEggMass
values.EggWeight = detail.TargetEggWeight
fcr = detail.StandardFCR
}
}
if growthMap, ok := growthDetailByStd[standardID]; ok {
if growth, ok := growthMap[week]; ok {
values.FeedIntake = growth.FeedIntake
values.MaxDepletion = growth.MaxDepletion
}
}
cache[cacheKey] = standardCacheEntry{values: values, fcr: fcr}
applyProductionStandardValues(item, values, fcr)
}
return nil
}
func applyProductionStandardValues(item *entity.Recording, values productionStandardValues, fcr *float64) {
item.StandardHenDay = values.HenDay
item.StandardHenHouse = values.HenHouse
item.StandardFeedIntake = values.FeedIntake
item.StandardMaxDepletion = values.MaxDepletion
item.StandardEggMass = values.EggMass
item.StandardEggWeight = values.EggWeight
item.StandardFcr = fcr
}
func RecordingWeekValue(e entity.Recording) int {
day := intValue(e.Day)
if day <= 0 {
return 0
}
weekBase := 1
if IsLayingRecording(e) {
weekBase = 18
}
return ((day - 1) / 7) + weekBase
}
func IsLayingRecording(e entity.Recording) bool {
if e.ProjectFlockKandang == nil {
return false
}
return strings.EqualFold(e.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying))
}
func intValue(value *int) int {
if value == nil {
return 0
}
return *value
}
+104 -3
View File
@@ -1,6 +1,9 @@
package recording package recording
import ( import (
"fmt"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
) )
@@ -28,12 +31,26 @@ func MapDepletions(recordingID uint, items []validation.Depletion) []entity.Reco
return nil return nil
} }
result := make([]entity.RecordingDepletion, 0, len(items)) aggregate := make(map[uint]float64, len(items))
for _, item := range 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{ result = append(result, entity.RecordingDepletion{
RecordingId: recordingID, RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId, ProductWarehouseId: warehouseID,
Qty: item.Qty, Qty: qty,
}) })
} }
return result return result
@@ -56,3 +73,87 @@ func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity.
} }
return result return result
} }
type EggTotals struct {
Qty int
Weight float64
}
func StockUsageByWarehouse(items []entity.RecordingStock) map[uint]float64 {
return TotalsByWarehouse(items, func(stock entity.RecordingStock) (uint, float64) {
var usage float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
return stock.ProductWarehouseId, usage
})
}
func StockUsageByWarehouseReq(items []validation.Stock) map[uint]float64 {
return TotalsByWarehouse(items, func(item validation.Stock) (uint, float64) {
return item.ProductWarehouseId, item.Qty
})
}
func FloatMapsEqual(a, b map[uint]float64) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
other, ok := b[key]
if !ok || !floatNearlyEqual(value, other) {
return false
}
}
return true
}
func EggTotalsEqual(a, b map[uint]EggTotals) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
other, ok := b[key]
if !ok || value.Qty != other.Qty || !floatNearlyEqual(value.Weight, other.Weight) {
return false
}
}
return true
}
func floatNearlyEqual(a, b float64) bool {
return a-b <= 0.000001 && b-a <= 0.000001
}
func TotalsByWarehouse[T any](items []T, get func(T) (uint, float64)) map[uint]float64 {
result := make(map[uint]float64)
for _, item := range items {
warehouseID, qty := get(item)
result[warehouseID] += qty
}
return result
}
func EggTotalsByWarehouse[T any](items []T, get func(T) (uint, int, *float64)) map[uint]EggTotals {
result := make(map[uint]EggTotals)
for _, item := range items {
warehouseID, qty, weightPtr := get(item)
weight := 0.0
if weightPtr != nil {
weight = *weightPtr
}
current := result[warehouseID]
current.Qty += qty
current.Weight += weight
result[warehouseID] = current
}
return result
}
func RecordingNote(action string, id uint) string {
action = strings.TrimSpace(action)
if action == "" {
return fmt.Sprintf("Recording#%d", id)
}
return fmt.Sprintf("Recording-%s#%d", action, id)
}