Compare commits

...

105 Commits

Author SHA1 Message Date
aguhh18 9a328ae1e4 FEAT[BE] :implement proportional distribution with rounding for stock allocation in transfer laying approval process 2026-02-03 08:06:52 +07:00
aguhh18 58ae03a090 FIX[BE] :remove unnecessary quantity calculations in Adjustment method to streamline stock adjustment logic 2026-02-02 21:45:18 +07:00
aguhh18 e406b20ca7 FEAT[BE] :Fixing fifo stock when marketing deleted 2026-02-02 21:11:27 +07:00
aguhh18 1c1f2f03aa FIX[BE] :remove unused product warehouse repository import and streamline stock consumption logic in consumeDeliveryStock method 2026-02-02 16:46:33 +07:00
aguhh18 ce108da847 FEAT[BE] :enhance production data calculations by adding TotalBirdSold and refining profit/loss metrics 2026-02-02 15:36:06 +07:00
aguhh18 5b80081d05 FIX[BE] :add handling for empty input in ToSummaryDto function to return zeroed summary 2026-02-02 13:02:30 +07:00
aguhh18 2cc89b31be Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-02-02 07:44:09 +07:00
aguhh18 3d92c4eb54 FEAT[BE] :refactor egg production data retrieval to use date parameter in GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds method 2026-02-02 07:42:22 +07:00
Adnan Zahir 75b822eb19 Merge branch 'production' into 'development'
Back Merge Production

See merge request mbugroup/lti-api!292
2026-01-31 10:55:55 +07:00
Adnan Zahir 3b661919c0 Merge branch 'fix/daily-checklist' into 'development'
[FIX][BE]: add validation max size upload file 5 MB

See merge request mbugroup/lti-api!289
2026-01-31 10:40:25 +07:00
Adnan Zahir 74146e90c6 Merge branch 'fix/sapronak' into 'development'
[FIX][BE]: fix filter by kandang

See merge request mbugroup/lti-api!288
2026-01-31 10:40:07 +07:00
aguhh18 1a9936eaa1 FIX[BE] :remove obsolete db_lti_erp-202601271102-stg.sql file 2026-01-30 19:45:11 +07:00
giovanni c257a2cb28 add validation max size upload file 5 MB 2026-01-30 15:09:23 +07:00
giovanni 8a403e803c fix filter by kandang 2026-01-30 14:47:13 +07:00
aguhh18 0707876a81 FEAT[BE] :update movement number format in GenerateMovementNumber method to include hyphen 2026-01-30 14:31:04 +07:00
Adnan Zahir c191776e6c Merge branch 'fix/daily-checklist' into 'development'
[FIX][BE]: fix search list daily checklist

See merge request mbugroup/lti-api!286
2026-01-30 14:04:18 +07:00
Adnan Zahir 4bdcadb898 Merge branch 'feat/inventory-logs' into 'development'
[FEAT][BE]: add migration add field stock to stock_logs; adjust logic stock for insert data stock_logs

See merge request mbugroup/lti-api!275
2026-01-30 13:27:16 +07:00
giovanni 4f9cd5131f Merge branch 'development' into feat/inventory-logs 2026-01-30 12:11:34 +07:00
giovanni 703c1548c9 fix search list daily checklist 2026-01-30 11:49:13 +07:00
Adnan Zahir 616250d38b Merge branch 'fix/data-production-age' into 'development'
[FIX][BE]: Fix/data production age

See merge request mbugroup/lti-api!284
2026-01-30 11:27:34 +07:00
giovanni a312b32aa2 Merge branch 'development' into fix/data-production-age 2026-01-30 10:48:06 +07:00
giovanni 9b6637066e fix calculate age at closing data production 2026-01-30 10:46:54 +07:00
Adnan Zahir 4496d211a1 Merge branch 'dev/teguh' into 'development'
FEAT[BE] :add validation if supplier id not null in transfer stock

See merge request mbugroup/lti-api!281
2026-01-30 10:38:09 +07:00
Adnan Zahir 2c93558d05 Merge branch 'FIX/BE/Marketing' into 'development'
FIX[BE]; fixing penjualan qty tidak berkurang jika qty tida ada dalam stockable

See merge request mbugroup/lti-api!283
2026-01-30 10:18:58 +07:00
aguhh18 d5b3120ea3 FEAT[BE] :add GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIdsAll method and update usage in calculateProductionData 2026-01-30 08:42:03 +07:00
aguhh18 4a85643aef Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-01-30 08:34:19 +07:00
aguhh18 22bd0e5891 FEAT[BE] :enhance UpdateOne method with validation for source and target kandang ownership and quantity checks 2026-01-30 08:24:43 +07:00
Adnan Zahir d1e3234f73 Merge branch 'feat/BE/sso-adjustment' into 'development'
Feat/be/sso adjustment

See merge request mbugroup/lti-api!273
2026-01-29 18:46:52 +07:00
aguhh18 3d1d9c418b FEAT[BE] :implement movement number generation and sequence management for transfer layings 2026-01-29 16:52:19 +07:00
aguhh18 3669f20f4a FEAT[BE] :add validation if supplier id not null in transfer stock 2026-01-29 14:50:46 +07:00
ragilap a21b554fc7 [FIX/BE-US] changes permission to redis and scope 2026-01-29 14:33:26 +07:00
aguhh18 86caa6c4d4 FIX[BE]; fixing penjualan qty tidak berkuranga 2026-01-29 12:24:32 +07:00
Hafizh A. Y. ef286949f8 Merge branch 'Fix/BE/US-281-adjustment-recording-egg-mass' into 'development'
[FIX/BE-US] delete *1000 egg mass

See merge request mbugroup/lti-api!274
2026-01-29 02:50:55 +00:00
Adnan Zahir 16157b135f Merge branch 'revert-392b2f51' into 'development'
Revert "Edit production.yml"

See merge request mbugroup/lti-api!280
2026-01-28 17:34:32 +07:00
kris a2012aa145 Edit production.yml 2026-01-28 10:34:31 +00:00
Adnan Zahir 478487870b Revert "Edit production.yml"
This reverts commit 392b2f51ad
2026-01-28 17:34:11 +07:00
kris 392b2f51ad Edit production.yml 2026-01-28 10:32:18 +00:00
Adnan Zahir 20a45bb71e Edit production.yml 2026-01-28 17:20:52 +07:00
Adnan Zahir 9c9cd1dd1b Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!279
2026-01-28 17:10:47 +07:00
Adnan Zahir bb8e4198f4 Merge branch 'development' into 'staging'
Activated migrate production no manual

See merge request mbugroup/lti-api!278
2026-01-28 17:03:46 +07:00
M1 AIR 6b58eb0c65 Activated migrate production no manual 2026-01-28 16:59:52 +07:00
kris 97a807f494 Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!277
2026-01-28 09:46:17 +00:00
kris bee9feafaf Merge branch 'development' into 'staging'
Activated migrate production

See merge request mbugroup/lti-api!276
2026-01-28 08:49:52 +00:00
M1 AIR d0dd12776e Activated migrate production 2026-01-28 15:48:05 +07:00
giovanni ac3623fa97 Merge branch 'development' into feat/inventory-logs 2026-01-28 13:44:26 +07:00
ragilap 705939bbf5 [FIX/BE-US] delete *1000 egg mass 2026-01-28 13:39:06 +07:00
giovanni 4d6f731f80 add migration add field stock to stock_logs; adjust logic stock for insert data stock_logs 2026-01-28 13:32:34 +07:00
Adnan Zahir a26ccb7811 Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!260
2026-01-28 13:24:37 +07:00
Adnan Zahir 24289796b9 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!272
2026-01-28 11:32:10 +07:00
Adnan Zahir 9dd4a93476 Merge branch 'feat/BE/sso-adjustment' into 'development'
[FIX/BE-US]add feature restrict by location and areas in roles

See merge request mbugroup/lti-api!189
2026-01-28 11:22:47 +07:00
Hafizh A. Y. b468ba3df9 Merge branch 'fix/path-s3' into 'development'
fix(BE): limit path to 255

See merge request mbugroup/lti-api!271
2026-01-28 04:22:10 +00:00
Hafizh A. Y 2127e9d1f4 fix(BE): limit path to 255 2026-01-28 11:21:47 +07:00
Hafizh A. Y. 1dae84101f Merge branch 'fix/path-s3' into 'development'
fix(BE): bug path s3

See merge request mbugroup/lti-api!270
2026-01-28 04:13:48 +00:00
Hafizh A. Y. 595ad6ed17 Merge branch 'FIX/BE/Expense' into 'development'
FIX/BE/Expense fixing tab not appear when expense attached to location only

See merge request mbugroup/lti-api!269
2026-01-28 04:13:30 +00:00
Hafizh A. Y. 8c71e7f2a3 Merge branch 'fix/BE/Purchase-edit-qty' into 'development'
[FIX/BE-US] fixing stock log delete recording and purchase

See merge request mbugroup/lti-api!267
2026-01-28 04:13:16 +00:00
Hafizh A. Y 43f9b660ce fix(BE): bug path s3 2026-01-28 11:12:22 +07:00
aguhh18 641b7ebd38 [FIX] fixing tab not appear when expense attached to location only 2026-01-28 11:01:27 +07:00
Hafizh A. Y. 396c1b5e45 Merge branch 'fix/path-s3' into 'development'
[FIX][BE] S3 prefix path

See merge request mbugroup/lti-api!268
2026-01-28 03:28:21 +00:00
Hafizh A. Y 2c169e7f83 fix(BE): s3 prefix path 2026-01-28 10:26:13 +07:00
ragilap 150f8e8606 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/Purchase-edit-qty 2026-01-28 10:09:05 +07:00
ragilap 1a0261ed36 [FIX/BE-US] fixing stock log delete recording and purchase 2026-01-28 10:08:41 +07:00
Hafizh A. Y. c3fe6b0463 Merge branch 'fix/daily-checklist' into 'development'
[FIX][BE]: add migration for change type data id tables module daily checklist

See merge request mbugroup/lti-api!266
2026-01-28 03:06:32 +00:00
Hafizh A. Y. 207e8ee3c4 Merge branch 'FEAT/BE/Transfer_stock' into 'development'
[FEAT][BE-transfer_Stock]L Update StockTransferDelivery and TransferDeliveryDTO to allow optional SupplierId

See merge request mbugroup/lti-api!265
2026-01-28 03:05:31 +00:00
Hafizh A. Y. f2195ae208 Merge branch 'FIX/BE/Closing_penjualan' into 'development'
FIX[BE-Closing_penjualan]: fixing penjualan ayam tidak mucnul di in closing penjualan

See merge request mbugroup/lti-api!263
2026-01-28 03:04:53 +00:00
giovanni 8086d2fb9e adjust query 2026-01-28 09:50:03 +07:00
giovanni 3d39d6d31e adjust validation select phase 2026-01-28 09:46:34 +07:00
aguhh18 3d70701d03 [FIX] Update validation for Query limit to remove max constraint 2026-01-28 09:21:44 +07:00
ragilap 8410573ee6 [FIX/BE-US] add transfer to laying filter location and area 2026-01-27 22:28:14 +07:00
giovanni 095080320c add migration for change type data id tables module daily checklist 2026-01-27 21:20:17 +07:00
aguhh18 e503a84660 [FEAT] Add stock logging for transfer laying process and introduce new StockLogType for transfer laying 2026-01-27 20:43:01 +07:00
aguhh18 6cac4f0243 [FEAT] Update StockTransferDelivery and TransferDeliveryDTO to allow optional SupplierId 2026-01-27 16:57:36 +07:00
Hafizh A. Y. 958dd4f241 Merge branch 'fix/sort-approval' into 'development'
fix(BE): change name from sort to order

See merge request mbugroup/lti-api!264
2026-01-27 08:31:17 +00:00
Hafizh A. Y 37f0324e2a fix(BE): change name from sort to order 2026-01-27 15:30:44 +07:00
aguhh18 77043005dd [FIX]BE: fixing penjualan ayam tidak mucnul di in closing penjualan 2026-01-27 14:53:51 +07:00
Hafizh A. Y. 8302345b30 Merge branch 'fix/data-produksi' into 'development'
[FIX][BE]: fix closing data produksi PENJUALAN

See merge request mbugroup/lti-api!261
2026-01-27 07:53:45 +00:00
Hafizh A. Y. 461877f75c Merge branch 'fix/sort-approval' into 'development'
fix(BE): sorting by date approval get all

See merge request mbugroup/lti-api!262
2026-01-27 07:53:21 +00:00
Hafizh A. Y f3ddd79974 fix(BE): sorting by date approval get all 2026-01-27 14:52:32 +07:00
ragilap 8848a50e3b Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/sso-adjustment 2026-01-27 14:22:18 +07:00
giovanni ae7e53ac1f add flag layer 2026-01-27 13:26:04 +07:00
giovanni ba20394a10 fix closing data produksi sales 2026-01-27 12:27:25 +07:00
Adnan Zahir b8de42b6fa Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!259
2026-01-27 11:49:30 +07:00
Adnan Zahir aa94c7cc02 Merge branch 'fix/BE/Report-purchasing-Debt-supplier-and-Closing-counting-sapronak' into 'development'
[FIX/BE-US] remove stock logs counting sapronak

See merge request mbugroup/lti-api!258
2026-01-27 11:47:39 +07:00
ragilap 5621c63e9a [FIX/BE-US] remove stock logs counting sapronak 2026-01-27 11:44:54 +07:00
ragilap 0920b91271 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/sso-adjustment 2026-01-27 10:46:49 +07:00
ragilap 00cdfb692b [FIX/BE-US] feat adjustment location and area 2026-01-27 10:34:25 +07:00
Adnan Zahir eef3c0f759 Merge branch 'development' into 'staging'
Staging

See merge request mbugroup/lti-api!257
2026-01-27 10:20:04 +07:00
Adnan Zahir d656eedfbe Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!252
2026-01-26 11:24:41 +07:00
ragilap 571f6b4bf3 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/sso-adjustment 2026-01-25 22:16:38 +07:00
ragilap 76d980878d Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/sso-adjustment 2026-01-24 19:03:52 +07:00
Adnan Zahir e60c08e09e Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!247
2026-01-24 12:37:28 +07:00
Adnan Zahir 33d3b18468 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!246
2026-01-24 12:14:46 +07:00
kris 302147787e Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!239
2026-01-22 09:06:53 +00:00
kris 19a268adfe Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!238
2026-01-22 09:00:17 +00:00
Adnan Zahir 41d3c21fef Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!231
2026-01-22 14:57:34 +07:00
Adnan Zahir ad5e832d41 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!236
2026-01-22 14:49:02 +07:00
kris ebac5f5a98 Merge branch 'development' into 'staging'
Fixing staging cicd

See merge request mbugroup/lti-api!234
2026-01-22 07:14:12 +00:00
kris a1832a6144 Merge branch 'development' into 'staging'
fixing pipeline

See merge request mbugroup/lti-api!233
2026-01-22 07:01:29 +00:00
Adnan Zahir c8912e503e Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!227
2026-01-22 13:52:03 +07:00
ragilap 3a457ceee5 [FIX/BE-US] adjustment sso-location 2026-01-22 09:27:11 +07:00
kris baa49b7cf1 Merge branch 'staging' into 'production'
Revert "Merge branch 'staging' into 'development'"

See merge request mbugroup/lti-api!225
2026-01-21 08:17:34 +00:00
ragilap a3e9017e29 [FIX/BE-US] changes role to user and query 2026-01-15 14:43:59 +07:00
ragilap 549e283757 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/sso-adjustment 2026-01-14 17:01:33 +07:00
ragilap 32772a63c8 Revert "Revert "[FIX/BE-US]add feature restrict by location and areas in roles""
This reverts commit 26bf7f165e.
2026-01-14 13:34:44 +07:00
ragilap 26bf7f165e Revert "[FIX/BE-US]add feature restrict by location and areas in roles"
This reverts commit dff9e73ab1.
2026-01-14 13:30:48 +07:00
ragilap dff9e73ab1 [FIX/BE-US]add feature restrict by location and areas in roles 2026-01-14 13:27:52 +07:00
82 changed files with 3934 additions and 1264 deletions
+70 -31
View File
@@ -1,6 +1,6 @@
stages: stages:
- build - build
# - migrate - migrate
- deploy - deploy
- seed - seed
@@ -49,41 +49,80 @@ build_production:
# ========================= # =========================
# MIGRATE (PRODUCTION - MANUAL) # MIGRATE (PRODUCTION)
# ========================= # =========================
#migrate_production: migrate_production:
# stage: migrate stage: migrate
# rules: rules:
# - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
# when: manual needs:
# allow_failure: false - job: build_production
# needs: artifacts: false
# - job: build_production script: |
# artifacts: false set -e
# script: | echo "✅ Running migrations (production) ..."
# set -e
# cd /opt/deploy/lti
# test -f .env || (echo "❌ .env not found" && exit 1)
# set -a cd "$DEPLOY_DIR"
# . ./.env test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
# set +a test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
# Validasi env wajib # ✅ load env dari server
# : "${DB_HOST:?DB_HOST not set}" set -a
# : "${DB_PORT:?DB_PORT not set}" . ./.env
# : "${DB_USER:?DB_USER not set}" set +a
# : "${DB_PASSWORD:?DB_PASSWORD not set}"
# : "${DB_NAME:?DB_NAME not set}"
# DB_SSLMODE="${DB_SSLMODE:-require}" # ✅ validasi
# export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
# echo "✅ Running migrations (production)..." export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
# docker run --rm \ echo "✅ DATABASE_URL=$DATABASE_URL"
# -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
# migrate/migrate:v4.15.2 \ # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!)
# -path=/migrations -database "$DATABASE_URL" up echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
# ✅ Ambil network key dari compose
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
# ✅ Cari network name yang dipakai docker
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Docker network detected: $NETWORK_NAME"
# ✅ Migrations dari repo (CI workspace)
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
echo "✅ Running migrations via migrate/migrate container"
set +e
out=$(docker run --rm \
--network "$NETWORK_NAME" \
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
migrate/migrate:v4.15.2 \
-path=/migrations -database "$DATABASE_URL" up 2>&1)
code=$?
set -e
echo "$out"
# ✅ Handle no change dengan benar (tidak false-success)
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
fi
if [ $code -ne 0 ]; then
echo "❌ Migration failed with exit code $code"
exit $code
fi
echo "✅ Migration applied successfully"
# ========================= # =========================
+1 -1
View File
@@ -14,8 +14,8 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/database" "gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
"gitlab.com/mbugroup/lti-api.git/internal/route" "gitlab.com/mbugroup/lti-api.git/internal/route"
"gitlab.com/mbugroup/lti-api.git/internal/sso"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -196,10 +196,10 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda
} }
func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) { func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
// if date == nil { if date == nil {
// now := time.Now() now := time.Now()
// date = &now date = &now
// } }
var totals struct { var totals struct {
TotalPieces float64 TotalPieces float64
@@ -253,7 +253,7 @@ func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangI
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id"). Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *endDate). Where("r.record_datetime <= ?", *endDate).
Where("mdp.delivery_date = ?", *startDate) Where("mdp.delivery_date <= ?", *startDate)
var totals struct { var totals struct {
TotalPieces float64 TotalPieces float64
@@ -15,7 +15,7 @@ type ApprovalService interface {
WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string
WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool) WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool)
CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error) CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error)
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string) ([]entity.Approval, int64, error) List(ctx context.Context, module string, approvableID *uint, page, limit int, search string, orderByDate string) ([]entity.Approval, int64, error)
ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error) ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error)
LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error) LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error)
LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error) LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error)
@@ -70,9 +70,14 @@ func (s *approvalService) List(
approvableID *uint, approvableID *uint,
page, limit int, page, limit int,
search string, search string,
orderByDate string,
) ([]entity.Approval, int64, error) { ) ([]entity.Approval, int64, error) {
module = strings.TrimSpace(strings.ToUpper(module)) module = strings.TrimSpace(strings.ToUpper(module))
search = strings.TrimSpace(search) search = strings.TrimSpace(search)
orderByDate = strings.TrimSpace(strings.ToUpper(orderByDate))
if orderByDate != "ASC" && orderByDate != "DESC" {
orderByDate = "DESC"
}
if limit <= 0 { if limit <= 0 {
limit = 10 limit = 10
@@ -90,7 +95,7 @@ func (s *approvalService) List(
func(db *gorm.DB) *gorm.DB { func(db *gorm.DB) *gorm.DB {
query := db. query := db.
Where("approvable_type = ?", module). Where("approvable_type = ?", module).
Order("action_at DESC"). Order("action_at " + orderByDate).
Preload("ActionUser") Preload("ActionUser")
if approvableID != nil { if approvableID != nil {
@@ -20,7 +20,7 @@ import (
) )
const ( const (
defaultDocumentPathLimit = 50 defaultDocumentPathLimit = 255
defaultDocumentKeyPrefix = "docs" defaultDocumentKeyPrefix = "docs"
maxDocumentNameLength = 50 maxDocumentNameLength = 50
) )
@@ -363,13 +363,19 @@ func (s *documentService) generateObjectKey(ext string) (string, error) {
} }
u := uuid.New().String() u := uuid.New().String()
key := fmt.Sprintf("%s/%s%s", strings.Trim(s.keyPrefix, "/"), u, normalizedExt) keyPrefix := strings.Trim(s.keyPrefix, "/")
if s.keyPrefix == "" { key := fmt.Sprintf("%s%s", u, normalizedExt)
key = fmt.Sprintf("%s%s", u, normalizedExt) if keyPrefix != "" {
key = fmt.Sprintf("%s/%s%s", keyPrefix, u, normalizedExt)
} }
if len(key) > s.maxPathLength { if len(key) > s.maxPathLength {
key = fmt.Sprintf("%s%s", u, normalizedExt) compact := strings.ReplaceAll(u, "-", "")
if keyPrefix != "" {
key = fmt.Sprintf("%s/%s%s", keyPrefix, compact, normalizedExt)
} else {
key = fmt.Sprintf("%s%s", compact, normalizedExt)
}
} }
if len(key) > s.maxPathLength { if len(key) > s.maxPathLength {
+23 -1
View File
@@ -61,6 +61,7 @@ var (
SSOCookieDomain string SSOCookieDomain string
SSOCookieSecure bool SSOCookieSecure bool
SSOCookieSameSite string SSOCookieSameSite string
SSOAccessTokenMaxBytes int
SSOTokenBlacklistPrefix string SSOTokenBlacklistPrefix string
SSOPKCETTL time.Duration SSOPKCETTL time.Duration
SSOUserSyncDrift time.Duration SSOUserSyncDrift time.Duration
@@ -73,6 +74,7 @@ var (
S3SecretKey string S3SecretKey string
S3ForcePathStyle bool S3ForcePathStyle bool
S3PublicBaseURL string S3PublicBaseURL string
S3EnvPrefix string
S3DocumentKeyPrefix string S3DocumentKeyPrefix string
) )
@@ -123,7 +125,12 @@ func init() {
S3SecretKey = strings.TrimSpace(viper.GetString("S3_SECRET_KEY")) S3SecretKey = strings.TrimSpace(viper.GetString("S3_SECRET_KEY"))
S3ForcePathStyle = viper.GetBool("S3_FORCE_PATH_STYLE") S3ForcePathStyle = viper.GetBool("S3_FORCE_PATH_STYLE")
S3PublicBaseURL = strings.TrimSuffix(strings.TrimSpace(viper.GetString("S3_PUBLIC_BASE_URL")), "/") S3PublicBaseURL = strings.TrimSuffix(strings.TrimSpace(viper.GetString("S3_PUBLIC_BASE_URL")), "/")
S3DocumentKeyPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/"), "docs") S3EnvPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_ENV_PREFIX")), "/"), "local")
docPrefix := strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/")
if docPrefix == "" {
docPrefix = "docs"
}
S3DocumentKeyPrefix = joinPath(S3EnvPrefix, docPrefix)
// SSO integration // SSO integration
SSOIssuer = viper.GetString("SSO_ISSUER") SSOIssuer = viper.GetString("SSO_ISSUER")
@@ -138,6 +145,10 @@ func init() {
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE") SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE")
SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax") SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax")
SSOAccessTokenMaxBytes = viper.GetInt("SSO_ACCESS_TOKEN_MAX_BYTES")
if SSOAccessTokenMaxBytes <= 0 {
SSOAccessTokenMaxBytes = 4096
}
SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist") SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist")
if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 { if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 {
SSOPKCETTL = time.Duration(ttl) * time.Second SSOPKCETTL = time.Duration(ttl) * time.Second
@@ -242,6 +253,17 @@ func defaultString(v, def string) string {
return v return v
} }
func joinPath(parts ...string) string {
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.Trim(part, "/")
if part != "" {
out = append(out, part)
}
}
return strings.Join(out, "/")
}
func ensureProdConfig() { 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")
@@ -0,0 +1,56 @@
BEGIN;
DO $$
DECLARE
t text;
seq_name text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'daily_checklist_activity_task_assignments',
'daily_checklist_activity_tasks',
'daily_checklist_phases',
'daily_checklist_tasks',
'daily_checklists',
'employee_kandangs',
'employees',
'phase_activities',
'phases'
]
LOOP
-- Sequence name convention
seq_name := format('public.%I_id_seq', t);
-- 1) Drop default nextval (bigserial behavior)
EXECUTE format(
'ALTER TABLE public.%I ALTER COLUMN id DROP DEFAULT',
t
);
-- 2) Add IDENTITY back (BY DEFAULT is safer for rollback)
EXECUTE format(
'ALTER TABLE public.%I ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY',
t
);
-- 3) Detach & optionally drop sequence (safe)
IF EXISTS (
SELECT 1 FROM pg_class
WHERE relkind = 'S'
AND relname = t || '_id_seq'
) THEN
EXECUTE format(
'ALTER SEQUENCE %s OWNED BY NONE',
seq_name
);
-- Optional: drop sequence (comment if you want to keep it)
EXECUTE format(
'DROP SEQUENCE IF EXISTS %s',
seq_name
);
END IF;
END LOOP;
END $$;
COMMIT;
@@ -0,0 +1,59 @@
BEGIN;
DO $$
DECLARE
t text;
seq_name text;
max_id bigint;
BEGIN
FOREACH t IN ARRAY ARRAY[
'daily_checklist_activity_task_assignments',
'daily_checklist_activity_tasks',
'daily_checklist_phases',
'daily_checklist_tasks',
'daily_checklists',
'employee_kandangs',
'employees',
'phase_activities',
'phases'
]
LOOP
-- Sequence name convention: public.<table>_id_seq
seq_name := format('public.%I_id_seq', t);
-- Drop IDENTITY only if the column is identity (safe to re-run)
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = t
AND column_name = 'id'
AND is_identity = 'YES'
) THEN
EXECUTE format('ALTER TABLE public.%I ALTER COLUMN id DROP IDENTITY', t);
END IF;
-- Ensure sequence exists
EXECUTE format('CREATE SEQUENCE IF NOT EXISTS %s', seq_name);
-- Set default like bigserial
EXECUTE format(
'ALTER TABLE public.%I ALTER COLUMN id SET DEFAULT nextval(''%s'')',
t, seq_name
);
-- Own the sequence by the column
EXECUTE format(
'ALTER SEQUENCE %s OWNED BY public.%I.id',
seq_name, t
);
-- Sync sequence to MAX(id) + 1 to avoid duplicate key
EXECUTE format('SELECT COALESCE(MAX(id), 0) FROM public.%I', t) INTO max_id;
EXECUTE format('SELECT setval(''%s'', $1, false)', seq_name)
USING (max_id + 1);
END LOOP;
END $$;
COMMIT;
@@ -0,0 +1,2 @@
ALTER TABLE stock_logs
DROP COLUMN stock;
@@ -0,0 +1,18 @@
ALTER TABLE stock_logs
ADD COLUMN stock NUMERIC(15, 3) NOT NULL DEFAULT 0;
WITH calc AS (
SELECT
id,
SUM(COALESCE(increase, 0) - COALESCE(decrease, 0))
OVER (
PARTITION BY product_warehouse_id
ORDER BY id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_stock
FROM stock_logs
)
UPDATE stock_logs t
SET stock = c.running_stock
FROM calc c
WHERE t.id = c.id;
@@ -0,0 +1,7 @@
BEGIN;
-- Migration: revert documents.path length
ALTER TABLE documents
ALTER COLUMN path TYPE VARCHAR(50);
COMMIT;
@@ -0,0 +1,7 @@
BEGIN;
-- Migration: extend documents.path length for environment prefixes
ALTER TABLE documents
ALTER COLUMN path TYPE VARCHAR(255);
COMMIT;
@@ -0,0 +1,2 @@
-- Drop transfer laying sequence
DROP SEQUENCE IF EXISTS transfer_laying_seq;
@@ -0,0 +1,33 @@
-- Create sequence for transfer laying movement number
CREATE SEQUENCE transfer_laying_seq START
WITH
1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 NO CYCLE;
-- Set sequence starting value based on existing data (if any)
-- This prevents duplicate movement numbers if there's already data
DO $$ DECLARE max_existing INTEGER;
BEGIN
-- Check if table exists and has data
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE
table_schema = 'public'
AND table_name = 'transfer_to_layings'
) THEN
-- Get max ID from existing records
SELECT COALESCE(MAX(id), 0) INTO max_existing
FROM transfer_to_layings;
-- Set sequence to start after the highest existing ID
IF max_existing > 0 THEN PERFORM setval (
'transfer_laying_seq',
max_existing
);
END IF;
END IF;
END $$;
+1 -1
View File
@@ -7,7 +7,7 @@ type Document struct {
DocumentableType string `gorm:"size:50;not null;index:documents_documentable_polymorphic,priority:1"` DocumentableType string `gorm:"size:50;not null;index:documents_documentable_polymorphic,priority:1"`
DocumentableId uint64 `gorm:"not null;index:documents_documentable_polymorphic,priority:2"` DocumentableId uint64 `gorm:"not null;index:documents_documentable_polymorphic,priority:2"`
Type string `gorm:"size:50;not null"` Type string `gorm:"size:50;not null"`
Path string `gorm:"size:50;not null"` Path string `gorm:"size:255;not null"`
Name string `gorm:"size:50;not null"` Name string `gorm:"size:50;not null"`
Ext string `gorm:"size:50;not null"` Ext string `gorm:"size:50;not null"`
Size float64 `gorm:"type:numeric(15,3);not null"` Size float64 `gorm:"type:numeric(15,3);not null"`
+1
View File
@@ -9,6 +9,7 @@ type StockLog struct {
Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"` Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"`
Decrease float64 `gorm:"column:decrease;type:numeric(15,3);default:0"` Decrease float64 `gorm:"column:decrease;type:numeric(15,3);default:0"`
Stock float64 `gorm:"column:stock;type:numeric(15,3);not null;default:0"`
LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"` LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"`
LoggableId uint `gorm:"column:loggable_id;not null"` LoggableId uint `gorm:"column:loggable_id;not null"`
+1 -1
View File
@@ -6,7 +6,7 @@ import "time"
type StockTransferDelivery struct { type StockTransferDelivery struct {
Id uint64 `gorm:"primaryKey;autoIncrement"` Id uint64 `gorm:"primaryKey;autoIncrement"`
StockTransferId uint64 StockTransferId uint64
SupplierId uint64 SupplierId *uint64
VehiclePlate string VehiclePlate string
DriverName string DriverName string
DocumentNumber string DocumentNumber string
+26 -8
View File
@@ -7,8 +7,8 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/config"
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/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/sso"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
@@ -24,6 +24,10 @@ type AuthContext struct {
User *entity.User User *entity.User
Roles []sso.Role Roles []sso.Role
Permissions map[string]struct{} Permissions map[string]struct{}
UserAreaIDs []uint
UserLocationIDs []uint
UserAllArea bool
UserAllLocation bool
} }
// Auth validates the incoming request against the central SSO access token and // Auth validates the incoming request against the central SSO access token and
@@ -67,15 +71,19 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
var roles []sso.Role var roles []sso.Role
permissions := make(map[string]struct{}) permissions := make(map[string]struct{})
var profile *sso.UserProfile
if verification.UserID != 0 { if verification.UserID != 0 {
if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { if p, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
} else if profile != nil { } else {
roles = profile.Roles profile = p
for _, perm := range profile.PermissionNames() { }
if perm != "" { }
permissions[perm] = struct{}{} if profile != nil {
} roles = profile.Roles
for _, perm := range profile.PermissionNames() {
if perm != "" {
permissions[perm] = struct{}{}
} }
} }
} }
@@ -86,6 +94,16 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
User: user, User: user,
Roles: roles, Roles: roles,
Permissions: permissions, Permissions: permissions,
UserAreaIDs: nil,
UserLocationIDs: nil,
UserAllArea: false,
UserAllLocation: false,
}
if profile != nil {
ctx.UserAreaIDs = profile.AreaIDs
ctx.UserLocationIDs = profile.LocationIDs
ctx.UserAllArea = profile.AllArea
ctx.UserAllLocation = profile.AllLocation
} }
c.Locals(authContextLocalsKey, ctx) c.Locals(authContextLocalsKey, ctx)
+636
View File
@@ -0,0 +1,636 @@
package middleware
import (
"errors"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
type ScopeFilter struct {
IDs []uint
Restrict bool
}
type roleScope struct {
allArea bool
allLocation bool
areaIDs []uint
locationIDs []uint
hasAnyScopes bool
}
func ResolveAreaScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) {
scope, err := collectRoleScope(c)
if err != nil || !scope.hasAnyScopes {
return ScopeFilter{}, err
}
if scope.allArea || scope.allLocation {
return ScopeFilter{}, nil
}
allowed := uniqueUint(scope.areaIDs)
if len(scope.locationIDs) > 0 {
derived, err := areaIDsByLocationIDs(db, scope.locationIDs)
if err != nil {
return ScopeFilter{}, err
}
allowed = uniqueUint(append(allowed, derived...))
}
if len(allowed) == 0 {
return ScopeFilter{Restrict: true}, nil
}
return ScopeFilter{IDs: allowed, Restrict: true}, nil
}
func ResolveLocationScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) {
scope, err := collectRoleScope(c)
if err != nil || !scope.hasAnyScopes {
return ScopeFilter{}, err
}
if scope.allLocation || scope.allArea {
return ScopeFilter{}, nil
}
areaIDs := uniqueUint(scope.areaIDs)
locationIDs := uniqueUint(scope.locationIDs)
switch {
case len(locationIDs) > 0 && len(areaIDs) > 0:
filtered, err := filterLocationIDsByAreaIDs(db, locationIDs, areaIDs)
if err != nil {
return ScopeFilter{}, err
}
locationIDs = filtered
case len(locationIDs) == 0 && len(areaIDs) > 0:
derived, err := locationIDsByAreaIDs(db, areaIDs)
if err != nil {
return ScopeFilter{}, err
}
locationIDs = derived
}
locationIDs = uniqueUint(locationIDs)
if len(locationIDs) == 0 {
return ScopeFilter{Restrict: true}, nil
}
return ScopeFilter{IDs: locationIDs, Restrict: true}, nil
}
func ResolveLocationAreaScopes(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, ScopeFilter, error) {
locationScope, err := ResolveLocationScope(c, db)
if err != nil {
return ScopeFilter{}, ScopeFilter{}, err
}
areaScope, err := ResolveAreaScope(c, db)
if err != nil {
return ScopeFilter{}, ScopeFilter{}, err
}
return locationScope, areaScope, nil
}
func collectRoleScope(c *fiber.Ctx) (roleScope, error) {
ctx, ok := AuthDetails(c)
if !ok || ctx == nil {
return roleScope{}, nil
}
userAreaIDs := uniqueUint(ctx.UserAreaIDs)
userLocationIDs := uniqueUint(ctx.UserLocationIDs)
userScope := roleScope{
allArea: ctx.UserAllArea,
allLocation: ctx.UserAllLocation,
areaIDs: userAreaIDs,
locationIDs: userLocationIDs,
hasAnyScopes: ctx.UserAllArea || ctx.UserAllLocation || len(userAreaIDs) > 0 || len(userLocationIDs) > 0,
}
if userScope.hasAnyScopes {
return userScope, nil
}
return roleScope{}, nil
}
func areaIDsByLocationIDs(db *gorm.DB, locationIDs []uint) ([]uint, error) {
if db == nil {
return nil, errors.New("database not configured")
}
if len(locationIDs) == 0 {
return nil, nil
}
var areaIDs []uint
if err := db.Model(&entity.Location{}).
Where("deleted_at IS NULL").
Where("id IN ?", locationIDs).
Distinct("area_id").
Pluck("area_id", &areaIDs).Error; err != nil {
return nil, err
}
return areaIDs, nil
}
func locationIDsByAreaIDs(db *gorm.DB, areaIDs []uint) ([]uint, error) {
if db == nil {
return nil, errors.New("database not configured")
}
if len(areaIDs) == 0 {
return nil, nil
}
var locationIDs []uint
if err := db.Model(&entity.Location{}).
Where("deleted_at IS NULL").
Where("area_id IN ?", areaIDs).
Distinct("id").
Pluck("id", &locationIDs).Error; err != nil {
return nil, err
}
return locationIDs, nil
}
func filterLocationIDsByAreaIDs(db *gorm.DB, locationIDs, areaIDs []uint) ([]uint, error) {
if db == nil {
return nil, errors.New("database not configured")
}
if len(locationIDs) == 0 || len(areaIDs) == 0 {
return nil, nil
}
var filtered []uint
if err := db.Model(&entity.Location{}).
Where("deleted_at IS NULL").
Where("id IN ?", locationIDs).
Where("area_id IN ?", areaIDs).
Distinct("id").
Pluck("id", &filtered).Error; err != nil {
return nil, err
}
return filtered, nil
}
func uniqueUint(ids []uint) []uint {
if len(ids) == 0 {
return nil
}
seen := make(map[uint]struct{}, len(ids))
result := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB {
if db == nil || !scope.Restrict {
return db
}
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
return db.Where(column+" IN ?", scope.IDs)
}
func ApplyLocationScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) {
scopeDB := db
if db != nil {
scopeDB = db.Session(&gorm.Session{NewDB: true})
}
scope, err := ResolveLocationScope(c, scopeDB)
if err != nil {
return db, err
}
return ApplyScopeFilter(db, scope, column), nil
}
func ApplyAreaScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) {
scopeDB := db
if db != nil {
scopeDB = db.Session(&gorm.Session{NewDB: true})
}
scope, err := ResolveAreaScope(c, scopeDB)
if err != nil {
return db, err
}
return ApplyScopeFilter(db, scope, column), nil
}
func ApplyLocationAreaScope(c *fiber.Ctx, db *gorm.DB, locationColumn, areaColumn string) (*gorm.DB, error) {
scopeDB := db
if db != nil {
scopeDB = db.Session(&gorm.Session{NewDB: true})
}
if locationColumn != "" {
locationScope, err := ResolveLocationScope(c, scopeDB)
if err != nil {
return db, err
}
db = ApplyScopeFilter(db, locationScope, locationColumn)
}
if areaColumn != "" {
areaScope, err := ResolveAreaScope(c, scopeDB)
if err != nil {
return db, err
}
db = ApplyScopeFilter(db, areaScope, areaColumn)
}
return db, nil
}
func EnsureWarehouseAccess(c *fiber.Ctx, db *gorm.DB, warehouseID uint) error {
if warehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid warehouse id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Warehouse{}).
Where("id = ?", warehouseID),
scope,
"warehouses.location_id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
}
return nil
}
func EnsureAreaAccess(c *fiber.Ctx, db *gorm.DB, areaID uint) error {
if areaID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid area id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveAreaScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Area not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Area{}).
Where("id = ?", areaID),
scope,
"areas.id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Area not found")
}
return nil
}
func EnsureLocationAccess(c *fiber.Ctx, db *gorm.DB, locationID uint) error {
if locationID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Location not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Location{}).
Where("id = ?", locationID),
scope,
"locations.id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Location not found")
}
return nil
}
func EnsureKandangAccess(c *fiber.Ctx, db *gorm.DB, kandangID uint) error {
if kandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Kandang{}).
Where("id = ?", kandangID),
scope,
"kandangs.location_id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
return nil
}
func EnsureProductWarehouseAccess(c *fiber.Ctx, db *gorm.DB, productWarehouseID uint) error {
if productWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product warehouse id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("product_warehouses pw").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("pw.id = ?", productWarehouseID)
q = ApplyScopeFilter(q, scope, "w.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found")
}
return nil
}
func EnsureStockLogAccess(c *fiber.Ctx, db *gorm.DB, stockLogID uint) error {
if stockLogID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid stock log id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Stock log not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("stock_logs sl").
Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("sl.id = ?", stockLogID)
q = ApplyScopeFilter(q, scope, "w.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Stock log not found")
}
return nil
}
func EnsureMarketingAccess(c *fiber.Ctx, db *gorm.DB, marketingID uint) error {
if marketingID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid marketing id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Marketing not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("marketings m").
Joins("JOIN marketing_products mp ON mp.marketing_id = m.id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("m.id = ?", marketingID)
q = ApplyScopeFilter(q, scope, "w.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Marketing not found")
}
return nil
}
func EnsureRecordingAccess(c *fiber.Ctx, db *gorm.DB, recordingID uint) error {
if recordingID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid recording id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Recording not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("recordings r").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("r.id = ?", recordingID)
q = ApplyScopeFilter(q, scope, "pf.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Recording not found")
}
return nil
}
func EnsureUniformityAccess(c *fiber.Ctx, db *gorm.DB, uniformityID uint) error {
if uniformityID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid uniformity id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("project_flock_kandang_uniformity u").
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)
q = ApplyScopeFilter(q, scope, "pf.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
return nil
}
func EnsureLayingTransferAccess(c *fiber.Ctx, db *gorm.DB, transferID uint) error {
if transferID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid transfer id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("laying_transfers lt").
Joins("JOIN project_flocks pf_from ON pf_from.id = lt.from_project_flock_id").
Joins("JOIN project_flocks pf_to ON pf_to.id = lt.to_project_flock_id").
Where("lt.id = ?", transferID).
Where("(pf_from.location_id IN ? OR pf_to.location_id IN ?)", scope.IDs, scope.IDs)
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
return nil
}
func EnsureProjectFlockAccess(c *fiber.Ctx, db *gorm.DB, projectFlockID uint) error {
if projectFlockID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.ProjectFlock{}).
Where("id = ?", projectFlockID),
scope,
"project_flocks.location_id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
}
return nil
}
func EnsureProjectFlockKandangAccess(c *fiber.Ctx, db *gorm.DB, projectFlockID, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project flock kandang id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("project_flock_kandangs").
Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id").
Where("project_flock_kandangs.id = ?", projectFlockKandangID)
if projectFlockID > 0 {
q = q.Where("project_flock_kandangs.project_flock_id = ?", projectFlockID)
}
q = ApplyScopeFilter(q, scope, "project_flocks.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
return nil
}
@@ -44,6 +44,15 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
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", "")) search := strings.TrimSpace(c.Query("search", ""))
orderByDate := strings.TrimSpace(c.Query("order_by_date", ""))
if orderByDate == "" {
orderByDate = "DESC"
} else {
orderByDate = strings.ToUpper(orderByDate)
if orderByDate != "ASC" && orderByDate != "DESC" {
return fiber.NewError(fiber.StatusBadRequest, "order_by_date must be either ASC or DESC")
}
}
query := &validation.Query{ query := &validation.Query{
ModuleName: moduleName, ModuleName: moduleName,
@@ -52,6 +61,7 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
Page: page, Page: page,
Limit: limit, Limit: limit,
Search: search, Search: search,
OrderByDate: orderByDate,
} }
records, totalResults, err := u.ApprovalService.List( records, totalResults, err := u.ApprovalService.List(
@@ -61,6 +71,7 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
query.Page, query.Page,
query.Limit, query.Limit,
query.Search, query.Search,
query.OrderByDate,
) )
if err != nil { if err != nil {
return err return err
@@ -7,4 +7,5 @@ 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,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"`
OrderByDate string `query:"order_by_date" validate:"omitempty,oneof=ASC DESC"`
} }
@@ -101,17 +101,44 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
} }
} }
func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
productFlags[i] = f.Name
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
}
ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
return SalesDTO{
Age: ageInDay,
Qty: e.UsageQty,
}
}
func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO {
var totalSalesPrice, totalActualPrice, sumSales, sumActual float64 var totalSalesPrice, totalActualPrice, sumSales, sumActual float64
count := len(e) count := len(e)
if count == 0 {
return SummaryDTO{
TotalSalesPrice: 0,
TotalActualPrice: 0,
AvgSalesPrice: 0,
AvgActualPrice: 0,
}
}
for _, item := range e { for _, item := range e {
totalSalesPrice += item.MarketingProduct.TotalPrice totalSalesPrice += item.MarketingProduct.TotalPrice
totalActualPrice += item.TotalPrice totalActualPrice += item.TotalPrice
sumSales += item.MarketingProduct.UnitPrice sumSales += item.MarketingProduct.UnitPrice
sumActual += item.UnitPrice sumActual += item.UnitPrice
} }
return SummaryDTO{ return SummaryDTO{
@@ -355,9 +355,10 @@ func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDs
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN products prod ON prod.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id").
Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("f.name IN ?", flagNames). Where("f.name IN ?", flagNames).
Select("COALESCE(SUM(mp.total_weight), 0) AS total_weight, COALESCE(SUM(mp.qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price"). Select("COALESCE(SUM(mdp.total_weight), 0) AS total_weight, COALESCE(SUM(mdp.usage_qty), 0) AS total_qty, COALESCE(SUM(mdp.total_price), 0) AS total_price").
Scan(&agg).Error Scan(&agg).Error
if err != nil { if err != nil {
return 0, 0, 0, err return 0, 0, 0, err
@@ -797,7 +798,7 @@ func (r *ClosingRepositoryImpl) detailQuery(
) *gorm.DB { ) *gorm.DB {
db := r.withCtx(ctx). db := r.withCtx(ctx).
Table(table). Table(table).
Joins("JOIN product_warehouses pw ON "+pwJoinCond). Joins("JOIN product_warehouses pw ON " + pwJoinCond).
Joins("JOIN products p ON p.id = pw.product_id") Joins("JOIN products p ON p.id = pw.product_id")
db = applyJoins(db, joins...) db = applyJoins(db, joins...)
@@ -906,7 +907,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
pi.received_date, pi.received_date,
st.transfer_date, st.transfer_date,
lt.transfer_date, lt.transfer_date,
sl.created_at, ast.created_at,
pc.chick_in_date, pc.chick_in_date,
r.record_datetime r.record_datetime
) AS date, ) AS date,
@@ -935,7 +936,6 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
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 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 stock_logs sl ON sl.id = ast.stock_log_id").
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").
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
@@ -951,7 +951,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
query = r.joinSapronakProductFlag(query, "p"). query = r.joinSapronakProductFlag(query, "p").
Group(` Group(`
pw.product_id, p.name, f.name, pw.product_id, p.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, sl.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.product_price
`) `)
@@ -1035,7 +1035,7 @@ func (r *ClosingRepositoryImpl) fetchStockLogs(ctx context.Context, kandangID ui
COALESCE(sl.increase,0) AS increase, COALESCE(sl.increase,0) AS increase,
COALESCE(sl.decrease,0) AS decrease, COALESCE(sl.decrease,0) AS decrease,
COALESCE(p.product_price,0) AS price, COALESCE(p.product_price,0) AS price,
`+movementSelect+` ` + movementSelect + `
`). `).
Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id"). Joins("JOIN products p ON p.id = pw.product_id").
@@ -12,6 +12,7 @@ import (
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"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
@@ -98,6 +99,11 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl
return nil, 0, err return nil, 0, err
} }
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
statusFilter := "" statusFilter := ""
if params.ProjectStatus != nil { if params.ProjectStatus != nil {
@@ -111,6 +117,12 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl
closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withClosingRelations(db) db = s.withClosingRelations(db)
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = m.ApplyScopeFilter(db, scope, "project_flocks.location_id")
}
if params.LocationID != nil { if params.LocationID != nil {
db = db.Where("location_id = ?", *params.LocationID) db = db.Where("location_id = ?", *params.LocationID)
} }
@@ -150,6 +162,10 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl
} }
func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) {
if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), id, s.withRelations) projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
@@ -161,13 +177,15 @@ func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.Proj
} }
func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
if projectFlockKandangID != nil {
projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
if err != nil { return nil, err
}
} else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err return nil, err
} }
realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID, projectFlock.Category) realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -179,8 +197,8 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectF
} }
func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (any, error) { func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (any, error) {
if projectFlockID == 0 { if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") return nil, err
} }
if kandangID != nil { if kandangID != nil {
@@ -264,7 +282,7 @@ func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectF
statusProject := "Belum Selesai" statusProject := "Belum Selesai"
var approvalDate string var approvalDate string
if s.ApprovalSvc != nil { if s.ApprovalSvc != nil {
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "") records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "", "")
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch approvals for project flock kandang %d: %+v", kandang.Id, err) s.Log.Errorf("Failed to fetch approvals for project flock kandang %d: %+v", kandang.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval data") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval data")
@@ -326,8 +344,8 @@ func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectF
} }
func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) { func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) {
if projectFlockID == 0 { if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") return nil, 0, err
} }
if params == nil { if params == nil {
@@ -349,15 +367,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
} }
if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil { warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID, params.KandangID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan")
}
s.Log.Errorf("Failed get project flock %d for sapronak closing: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock")
@@ -441,7 +451,7 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
} }
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID, params.KandangID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock")
@@ -484,13 +494,16 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
return items, nil return items, nil
} }
func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint, kandangID *uint) ([]uint, error) {
var kandangIDs []uint var kandangIDs []uint
db := s.Repository.DB().WithContext(ctx) db := s.Repository.DB().WithContext(ctx)
if err := db.Model(&entity.ProjectFlockKandang{}). query := db.Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ?", projectFlockID). Where("project_flock_id = ?", projectFlockID)
Pluck("kandang_id", &kandangIDs).Error; err != nil { if kandangID != nil && *kandangID > 0 {
query = query.Where("id = ?", *kandangID)
}
if err := query.Pluck("kandang_id", &kandangIDs).Error; err != nil {
return nil, err return nil, err
} }
@@ -542,7 +555,7 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID
return "", "Belum Selesai", nil return "", "Belum Selesai", nil
} }
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "") records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "", "")
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -585,6 +598,14 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID
} }
func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) { func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) {
if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
return nil, err
}
} else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err
}
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -673,8 +694,12 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
} }
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 projectFlockID == 0 { if projectFlockKandangID != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
return nil, err
}
} else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err
} }
rows, err := s.Repository.GetExpeditionHPP(c.Context(), projectFlockID, projectFlockKandangID) rows, err := s.Repository.GetExpeditionHPP(c.Context(), projectFlockID, projectFlockKandangID)
@@ -819,7 +844,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data")
} }
} }
age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID) 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)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data")
@@ -839,7 +864,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
// FeedUsedPerHead: feedUsedPerHead, // FeedUsedPerHead: feedUsedPerHead,
} }
chickenFlagNames := []string{string(utils.FlagPullet)} chickenFlagNames := []string{string(utils.FlagPullet), string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagLayer)}
chickenSalesWeight, chickenSalesQty, chickenSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, chickenFlagNames) chickenSalesWeight, chickenSalesQty, chickenSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, chickenFlagNames)
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch chicken sales data for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to fetch chicken sales data for project flock %d: %+v", projectFlockID, err)
@@ -1006,38 +1031,24 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
return &result, nil return &result, nil
} }
func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint) (float64, error) { func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) (float64, error) {
deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(ctx, projectFlockID, func(db *gorm.DB) *gorm.DB { penjualan, err := s.MarketingDeliveryProductRepo.GetClosingPenjualanForAgeChickDataProduction(ctx, projectFlockID, projectFlockKandangID)
return db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins")
})
if err != nil { if err != nil {
return 0, err return 0, err
} }
acumulateAgeQty := 0.0
var ( totalQty := 0.0
totalQty float64 for _, v := range penjualan {
totalAgeWeeks float64 sale := dto.ToSalesAgeDTO(v)
) acumulateAgeQty += float64(sale.Age) * sale.Qty
totalQty += sale.Qty
for _, product := range deliveryProducts { }
if product.UsageQty == 0 { if totalQty > 0 {
continue averageAge := acumulateAgeQty / totalQty
} return averageAge, nil
projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang
ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate)
totalAgeWeeks += float64(ageWeeks) * product.UsageQty
totalQty += product.UsageQty
} }
if totalQty == 0 { return 0, err
return 0, nil
}
return totalAgeWeeks / totalQty, nil
} }
func (s closingService) determineProductionWeek(ctx context.Context, projectFlockKandangIDs []uint) (int, error) { func (s closingService) determineProductionWeek(ctx context.Context, projectFlockKandangIDs []uint) (int, error) {
@@ -41,6 +41,7 @@ type ProductionData struct {
TotalWeightProduced float64 TotalWeightProduced float64
TotalEggWeightKg float64 TotalEggWeightKg float64
TotalWeightSold float64 TotalWeightSold float64
TotalBirdSold float64
TotalSalesAmount float64 TotalSalesAmount float64
} }
@@ -270,9 +271,9 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo
var deliveryProducts []entity.MarketingDeliveryProduct var deliveryProducts []entity.MarketingDeliveryProduct
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlock.Id, projectFlockKandangID, projectFlock.Category) deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, projectFlockKandangID, projectFlock.Category)
} else { } else {
deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlock.Id, nil, projectFlock.Category) deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, nil, projectFlock.Category)
} }
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data penjualan") return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data penjualan")
@@ -283,6 +284,7 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo
continue continue
} }
data.TotalWeightSold += delivery.TotalWeight data.TotalWeightSold += delivery.TotalWeight
data.TotalBirdSold += delivery.UsageQty
data.TotalSalesAmount += delivery.TotalPrice data.TotalSalesAmount += delivery.TotalPrice
} }
@@ -383,46 +385,77 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti
func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection { func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection {
totalPopulationIn := production.TotalPopulationIn
totalWeightProduced := production.TotalWeightProduced totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg totalEggWeightKg := production.TotalEggWeightKg
totalSalesAmount := production.TotalSalesAmount totalSalesAmount := production.TotalSalesAmount
totalWeightSold := production.TotalWeightSold totalWeightSold := production.TotalWeightSold
totalBirdSold := production.TotalBirdSold
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
weightForSales := totalWeightSold isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying)
weightForCalculation := totalWeightProduced
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
weightForSales = totalWeightSold
weightForCalculation = totalEggWeightKg
}
calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { // Fungsi untuk sales: LAYING = populasi aktual, GROWING = ekor terjual
if totalPopulationIn > 0 { calculateSalesMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
rpPerBird = amount / totalPopulationIn if isLaying {
} if actualPopulation > 0 {
if weightForSales > 0 { rpPerBird = amount / actualPopulation
rpPerKg = amount / weightForSales }
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
} else {
if totalBirdSold > 0 {
rpPerBird = amount / totalBirdSold
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
} }
return return
} }
actualPopulation := production.TotalPopulationIn - production.TotalDepletion // Fungsi untuk cost: per ekor = populasi aktual, per kg = LAYING telur produksi / GROWING ayam produksi
calculateCostMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if actualPopulation > 0 { if actualPopulation > 0 {
rpPerBird = amount / actualPopulation rpPerBird = amount / actualPopulation
} }
if weightForCalculation > 0 { if isLaying {
rpPerKg = amount / weightForCalculation if totalEggWeightKg > 0 {
rpPerKg = amount / totalEggWeightKg
}
} else {
if totalWeightProduced > 0 {
rpPerKg = amount / totalWeightProduced
}
}
return
}
// Fungsi untuk overhead/ekspedisi: LAYING = populasi aktual, GROWING = ekor terjual
calculateOverheadMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if isLaying {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
} else {
if totalBirdSold > 0 {
rpPerBird = amount / totalBirdSold
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
} }
return return
} }
plItems := []dto.ProfitLossItem{} plItems := []dto.ProfitLossItem{}
salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount) salesRpPerBird, salesRpPerKg := calculateSalesMetrics(totalSalesAmount)
salesLabel := "Penjualan Ayam" salesLabel := "Penjualan Ayam"
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { if isLaying {
salesLabel = "Penjualan Telur" salesLabel = "Penjualan Telur"
} }
plItems = append(plItems, dto.ToProfitLossItem( plItems = append(plItems, dto.ToProfitLossItem(
@@ -435,23 +468,23 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
)) ))
totalSapronakAmount := costs.ChickenCost + costs.FeedCost + costs.OvkCost totalSapronakAmount := costs.ChickenCost + costs.FeedCost + costs.OvkCost
_, sapronakRpPerKg := calculateMetrics(totalSapronakAmount)
sapronakRpPerBird := 0.0 sapronakRpPerBird := 0.0
sapronakRpPerKg := 0.0
for _, amount := range []float64{costs.ChickenCost, costs.FeedCost, costs.OvkCost} { for _, amount := range []float64{costs.ChickenCost, costs.FeedCost, costs.OvkCost} {
rpPerBird, _ := calculateMetrics(amount) rpPerBird, rpPerKg := calculateCostMetrics(amount)
sapronakRpPerBird += rpPerBird sapronakRpPerBird += rpPerBird
sapronakRpPerKg += rpPerKg
} }
sapronakLabel := "Pengeluaran Sapronak"
plItems = append(plItems, dto.ToProfitLossItem( plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeSapronak), string(dto.PLCodeSapronak),
sapronakLabel, "Pengeluaran Sapronak",
"purchase", "purchase",
sapronakRpPerBird, sapronakRpPerBird,
sapronakRpPerKg, sapronakRpPerKg,
totalSapronakAmount, totalSapronakAmount,
)) ))
overheadRpPerBird, overheadRpPerKg := calculateProfitLossMetrics(costs.RealizationOperational) overheadRpPerBird, overheadRpPerKg := calculateOverheadMetrics(costs.RealizationOperational)
plItems = append(plItems, dto.ToProfitLossItem( plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeOverhead), string(dto.PLCodeOverhead),
"Overhead", "Overhead",
@@ -461,7 +494,7 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
costs.RealizationOperational, costs.RealizationOperational,
)) ))
ekspedisiRpPerBird, ekspedisiRpPerKg := calculateProfitLossMetrics(costs.ExpeditionCost) ekspedisiRpPerBird, ekspedisiRpPerKg := calculateOverheadMetrics(costs.ExpeditionCost)
plItems = append(plItems, dto.ToProfitLossItem( plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeEkspedisi), string(dto.PLCodeEkspedisi),
"Ekspedisi", "Ekspedisi",
@@ -2,6 +2,7 @@ package controller
import ( import (
"math" "math"
"mime/multipart"
"strconv" "strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto"
@@ -362,6 +363,9 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form")
} }
req.Documents = form.File["documents"] req.Documents = form.File["documents"]
if err := validateDailyChecklistDocumentSizes(req.Documents); err != nil {
return err
}
if err := c.BodyParser(req); err != nil { if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
@@ -381,6 +385,16 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
}) })
} }
func validateDailyChecklistDocumentSizes(files []*multipart.FileHeader) error {
const maxDailyChecklistDocumentBytes = 5 * 1024 * 1024 // 5MB
for _, file := range files {
if file != nil && file.Size > maxDailyChecklistDocumentBytes {
return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB")
}
}
return nil
}
func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error { func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("idDailyChecklist") param := c.Params("idDailyChecklist")
@@ -3,13 +3,14 @@ package service
import ( import (
"errors" "errors"
"math" "math"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations"
phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories"
@@ -134,6 +135,87 @@ func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("Kandang") return db.Preload("Kandang")
} }
func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error {
if checklistID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid checklist id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id = ?", checklistID)
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return nil
}
func (s dailyChecklistService) ensureKandangAccess(c *fiber.Ctx, kandangID uint) error {
if kandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("kandangs k").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("k.id = ?", kandangID)
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
return nil
}
func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error {
if taskID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid task id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_tasks t").
Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("t.id = ?", taskID)
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Task not found")
}
return nil
}
func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]DailyChecklistListItem, int64, error) { func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]DailyChecklistListItem, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, err return nil, 0, err
@@ -143,7 +225,15 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
db := s.Repository.DB().WithContext(c.Context()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc"). Table("daily_checklists dc").
Joins("JOIN kandangs k ON k.id = dc.kandang_id") Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id")
var scopeErr error
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if scopeErr != nil {
return nil, 0, scopeErr
}
if params.DateFrom != "" { if params.DateFrom != "" {
dateFrom, err := time.Parse("2006-01-02", params.DateFrom) dateFrom, err := time.Parse("2006-01-02", params.DateFrom)
@@ -170,8 +260,9 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
} }
if params.Search != "" { if params.Search != "" {
like := "%" + params.Search + "%" re := regexp.MustCompile("[^a-zA-Z0-9]")
db = db.Where("(k.name ILIKE ? OR dc.category::text ILIKE ?)", like, like) like := re.ReplaceAll([]byte("%"+params.Search+"%"), []byte(""))
db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", string(like), string(like))
} }
countDB := db.Session(&gorm.Session{}) countDB := db.Session(&gorm.Session{})
@@ -294,6 +385,9 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
} }
func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) { func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) {
if err := s.ensureChecklistAccess(c, id); err != nil {
return nil, err
}
dailyChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations) dailyChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
@@ -399,6 +493,9 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := s.ensureKandangAccess(c, req.KandangId); err != nil {
return nil, err
}
date, err := time.Parse("2006-01-02", req.Date) date, err := time.Parse("2006-01-02", req.Date)
if err != nil { if err != nil {
@@ -431,6 +528,9 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := s.ensureChecklistAccess(c, id); err != nil {
return nil, err
}
deletedIDs := make([]uint, 0) deletedIDs := make([]uint, 0)
if req.DeletedDocumentIDs != nil { if req.DeletedDocumentIDs != nil {
@@ -456,7 +556,7 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
updateBody["reject_reason"] = *req.RejectReason updateBody["reject_reason"] = *req.RejectReason
} }
actorID, err := middleware.ActorIDFromContext(c) actorID, err := m.ActorIDFromContext(c)
if err != nil { if err != nil {
return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
} }
@@ -502,6 +602,9 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
} }
func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
@@ -516,6 +619,9 @@ func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validati
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return err return err
} }
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -597,6 +703,9 @@ func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validati
} }
func (s dailyChecklistService) RemoveAssignment(c *fiber.Ctx, id uint, employeeID uint) error { func (s dailyChecklistService) RemoveAssignment(c *fiber.Ctx, id uint, employeeID uint) error {
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
@@ -634,6 +743,9 @@ func (s dailyChecklistService) GetTasks(c *fiber.Ctx, checklistID uint) ([]entit
if checklistID == 0 { if checklistID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required")
} }
if err := s.ensureChecklistAccess(c, checklistID); err != nil {
return nil, err
}
if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil { if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -658,6 +770,9 @@ func (s dailyChecklistService) GetChecklistPhaseIDs(c *fiber.Ctx, checklistID ui
if checklistID == 0 { if checklistID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required")
} }
if err := s.ensureChecklistAccess(c, checklistID); err != nil {
return nil, err
}
if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil { if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -687,6 +802,9 @@ func (s dailyChecklistService) UpdateAssignment(c *fiber.Ctx, req *validation.Up
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return err return err
} }
if err := s.ensureTaskAccess(c, req.TaskID); err != nil {
return err
}
task := new(entity.DailyChecklistActivityTask) task := new(entity.DailyChecklistActivityTask)
if err := s.Repository.DB().WithContext(c.Context()).First(task, req.TaskID).Error; err != nil { if err := s.Repository.DB().WithContext(c.Context()).First(task, req.TaskID).Error; err != nil {
@@ -808,6 +926,9 @@ func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validatio
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return err return err
} }
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
employeeIDs, err := parseIDs(req.EmployeeIDs) employeeIDs, err := parseIDs(req.EmployeeIDs)
if err != nil { if err != nil {
@@ -900,8 +1021,16 @@ func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.Summa
Joins("JOIN daily_checklists d ON d.id = t.checklist_id"). Joins("JOIN daily_checklists d ON d.id = t.checklist_id").
Joins("JOIN kandangs k ON k.id = d.kandang_id"). Joins("JOIN kandangs k ON k.id = d.kandang_id").
Joins("JOIN employees e ON e.id = a.employee_id"). Joins("JOIN employees e ON e.id = a.employee_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas ar ON ar.id = loc.area_id").
Where("d.date BETWEEN ? AND ? AND d.status = ?", dateFrom, dateTo, "APPROVED") Where("d.date BETWEEN ? AND ? AND d.status = ?", dateFrom, dateTo, "APPROVED")
var scopeErr error
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "ar.id")
if scopeErr != nil {
return nil, scopeErr
}
if params.Category != "" { if params.Category != "" {
db = db.Where("d.category = ?", params.Category) db = db.Where("d.category = ?", params.Category)
} }
@@ -946,6 +1075,15 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
return nil, 0, err return nil, 0, err
} }
locationScope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
areaScope, err := m.ResolveAreaScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
buildBase := func() *gorm.DB { buildBase := func() *gorm.DB {
@@ -962,6 +1100,9 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year). Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year).
Where("dc.status = ?", "APPROVED") Where("dc.status = ?", "APPROVED")
db = m.ApplyScopeFilter(db, locationScope, "loc.id")
db = m.ApplyScopeFilter(db, areaScope, "a.id")
if params.AreaID != nil { if params.AreaID != nil {
db = db.Where("a.id = ?", *params.AreaID) db = db.Where("a.id = ?", *params.AreaID)
} }
@@ -29,7 +29,7 @@ type Query struct {
} }
type AssignPhases struct { type AssignPhases struct {
PhaseIDs string `json:"phase_ids" validate:"required"` PhaseIDs string `json:"phase_ids" validate:"omitempty"`
} }
type AssignTask struct { type AssignTask struct {
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
@@ -81,6 +82,20 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid include") return fiber.NewError(fiber.StatusBadRequest, "Invalid include")
} }
scope, err := m.ResolveLocationScope(c, u.DashboardService.DB())
if err != nil {
return err
}
if scope.Restrict {
if len(scope.IDs) == 0 {
lokasiIds = []uint{}
} else if len(lokasiIds) > 0 {
lokasiIds = intersectUint(lokasiIds, scope.IDs)
} else {
lokasiIds = scope.IDs
}
}
analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview))) analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview)))
metric := strings.ToLower(strings.TrimSpace(c.Query("metric", ""))) metric := strings.ToLower(strings.TrimSpace(c.Query("metric", "")))
@@ -176,6 +191,23 @@ func defaultUintSlice(values []uint) []uint {
return values return values
} }
func intersectUint(a, b []uint) []uint {
if len(a) == 0 || len(b) == 0 {
return nil
}
set := make(map[uint]struct{}, len(b))
for _, id := range b {
set[id] = struct{}{}
}
out := make([]uint, 0, len(a))
for _, id := range a {
if _, ok := set[id]; ok {
out = append(out, id)
}
}
return out
}
func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) { func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) {
now := time.Now().In(location) now := time.Now().In(location)
startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location)
@@ -572,11 +572,17 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context
} }
var rows []ComparisonWeeklyMetric var rows []ComparisonWeeklyMetric
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(fmt.Sprintf(`(CASE WHEN r.day IS NULL OR r.day <= 0 THEN 1 ELSE ((r.day - 1) / 7 + 1) END) AS week, Select(fmt.Sprintf(`%s AS week,
%s AS series_id, %s AS series_id,
COALESCE(AVG(%s), 0) AS value`, seriesExpr, metricExpr)). COALESCE(AVG(%s), 0) AS value`, weekExpr, seriesExpr, metricExpr)).
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 project_flocks AS pf ON pf.id = pfk.project_flock_id").
@@ -18,10 +18,12 @@ import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm"
) )
type DashboardService interface { type DashboardService interface {
GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error)
DB() *gorm.DB
} }
type dashboardService struct { type dashboardService struct {
@@ -40,6 +42,10 @@ func NewDashboardService(repo repository.DashboardRepository, validate *validato
} }
} }
func (s dashboardService) DB() *gorm.DB {
return s.Repository.DB()
}
func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) { func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return dto.DashboardPerformanceOverviewDTO{}, 0, err return dto.DashboardPerformanceOverviewDTO{}, 0, err
@@ -328,6 +328,7 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
} }
kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r)
} else { } else {
directRealisasi = append(directRealisasi, r)
} }
} }
@@ -139,9 +139,28 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
locationID := filters.LocationId locationID := filters.LocationId
areaID := filters.AreaId areaID := filters.AreaId
if locationID > 0 || areaID > 0 { if filters.AllowedLocationIDs != nil || filters.AllowedAreaIDs != nil || locationID > 0 || areaID > 0 {
db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id") db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id")
}
if filters.AllowedLocationIDs != nil {
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("kandangs.location_id IN ?", filters.AllowedLocationIDs)
}
}
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Joins("JOIN locations ON locations.id = kandangs.location_id").
Where("locations.area_id IN ?", filters.AllowedAreaIDs)
}
}
if locationID > 0 || areaID > 0 {
if locationID > 0 { if locationID > 0 {
db = db.Where("kandangs.location_id = ?", uint(locationID)) db = db.Where("kandangs.location_id = ?", uint(locationID))
} }
@@ -87,16 +87,22 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
return nil, 0, err return nil, 0, err
} }
var scopeErr error
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
if params.Search != "" { if params.Search != "" {
return db.Where("category ILIKE ?", "%"+params.Search+"%") return db.Where("category ILIKE ?", "%"+params.Search+"%")
} }
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
if scopeErr != nil {
return nil, 0, scopeErr
}
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -117,7 +123,16 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
} }
func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) {
expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations) var scopeErr error
expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
return db
})
if scopeErr != nil {
return nil, scopeErr
}
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -74,6 +74,10 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
} }
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) {
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
return nil, err
}
adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, s.withRelations) adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, s.withRelations)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -95,6 +99,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), uint(req.WarehouseID)); err != nil {
return nil, err
}
if err := common.EnsureRelations(c.Context(), if err := common.EnsureRelations(c.Context(),
common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists}, common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists},
common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists}, common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists},
@@ -153,7 +160,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse")
} }
afterQuantity := productWarehouse.Quantity
newLog := &entity.StockLog{ newLog := &entity.StockLog{
LoggableType: string(utils.StockLogTypeAdjustment), LoggableType: string(utils.StockLogTypeAdjustment),
LoggableId: 0, LoggableId: 0,
@@ -162,15 +168,28 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
CreatedBy: actorID, CreatedBy: actorID,
} }
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(ctx, productWarehouse.Id, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
newLog.Stock = latestStockLog.Stock
} else {
newLog.Stock = 0
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) { if transactionType == string(utils.StockLogTransactionTypeIncrease) {
afterQuantity += req.Quantity
newLog.Increase = req.Quantity newLog.Increase = req.Quantity
newLog.Stock += newLog.Increase
} else { } else {
if productWarehouse.Quantity < req.Quantity { if productWarehouse.Quantity < req.Quantity {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity))
} }
afterQuantity -= req.Quantity
newLog.Decrease = req.Quantity newLog.Decrease = req.Quantity
newLog.Stock -= newLog.Decrease
} }
if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil {
@@ -221,12 +240,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
} }
} }
productWarehouse.Quantity = afterQuantity
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil {
s.Log.Errorf("Failed to update product warehouse quantity: %+v", err)
return err
}
createdAdjustmentStockId = adjustmentStock.Id createdAdjustmentStockId = adjustmentStock.Id
return nil return nil
}) })
@@ -304,6 +317,19 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
Preload("ProductWarehouse.Warehouse"). Preload("ProductWarehouse.Warehouse").
Preload("StockLog.CreatedUser") Preload("StockLog.CreatedUser")
scope, scopeErr := m.ResolveLocationScope(c, s.AdjustmentStockRepository.DB())
if scopeErr != nil {
return nil, 0, scopeErr
}
if scope.Restrict {
if len(scope.IDs) == 0 {
return []*entity.AdjustmentStock{}, 0, nil
}
q = q.Joins("JOIN product_warehouses pw_scope ON pw_scope.id = adjustment_stocks.product_warehouse_id").
Joins("JOIN warehouses w_scope ON w_scope.id = pw_scope.warehouse_id")
q = m.ApplyScopeFilter(q, scope, "w_scope.location_id")
}
if query.ProductID > 0 { if query.ProductID > 0 {
q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id"). q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id").
Where("product_warehouses.product_id = ?", query.ProductID) Where("product_warehouses.product_id = ?", query.ProductID)
@@ -4,6 +4,7 @@ import (
"errors" "errors"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations"
productRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" productRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -36,19 +37,49 @@ func NewProductStockService(
} }
} }
func (s productStockService) withRelations(db *gorm.DB) *gorm.DB { func (s productStockService) withRelations(db *gorm.DB, locationScope, areaScope m.ScopeFilter) *gorm.DB {
warehouseScope := func(db *gorm.DB) *gorm.DB {
if locationScope.Restrict {
db = db.Where("warehouses.location_id IN ?", locationScope.IDs)
}
if areaScope.Restrict {
db = db.Where("warehouses.area_id IN ?", areaScope.IDs)
}
return db
}
productWarehouseScope := func(db *gorm.DB) *gorm.DB {
db = db.Joins("JOIN warehouses w ON w.id = product_warehouses.warehouse_id")
if locationScope.Restrict {
db = db.Where("w.location_id IN ?", locationScope.IDs)
}
if areaScope.Restrict {
db = db.Where("w.area_id IN ?", areaScope.IDs)
}
return db
}
stockLogScope := func(db *gorm.DB) *gorm.DB {
db = db.
Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id")
if locationScope.Restrict {
db = db.Where("w.location_id IN ?", locationScope.IDs)
}
if areaScope.Restrict {
db = db.Where("w.area_id IN ?", areaScope.IDs)
}
return db.Order("stock_logs.created_at ASC")
}
return db. return db.
Preload("CreatedUser"). Preload("CreatedUser").
Preload("Uom"). Preload("Uom").
Preload("ProductCategory"). Preload("ProductCategory").
Preload("Flags"). Preload("Flags").
Preload("ProductWarehouses"). Preload("ProductWarehouses", productWarehouseScope).
Preload("ProductWarehouses.Warehouse"). Preload("ProductWarehouses.Warehouse", warehouseScope).
Preload("ProductWarehouses.Warehouse.Location"). Preload("ProductWarehouses.Warehouse.Location").
Preload("ProductWarehouses.Warehouse.Location.Area"). Preload("ProductWarehouses.Warehouse.Location.Area").
Preload("ProductWarehouses.StockLogs", func(db *gorm.DB) *gorm.DB { Preload("ProductWarehouses.StockLogs", stockLogScope).
return db.Order("created_at ASC")
}).
Preload("ProductWarehouses.StockLogs.CreatedUser"). Preload("ProductWarehouses.StockLogs.CreatedUser").
Preload("ProductSuppliers"). Preload("ProductSuppliers").
Preload("ProductSuppliers.Supplier", func(db *gorm.DB) *gorm.DB { Preload("ProductSuppliers.Supplier", func(db *gorm.DB) *gorm.DB {
@@ -61,17 +92,40 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
return nil, 0, err return nil, 0, err
} }
locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.ProductRepository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = db.Where(`EXISTS ( if locationScope.Restrict || areaScope.Restrict {
SELECT 1 if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) {
FROM product_warehouses pw return db.Where("1 = 0")
WHERE pw.product_id = products.id }
AND pw.qty > 0 db = db.Where(`EXISTS (
)`) SELECT 1
FROM product_warehouses pw
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE pw.product_id = products.id
AND pw.qty > 0
AND (? OR w.location_id IN ?)
AND (? OR w.area_id IN ?)
)`,
!locationScope.Restrict, locationScope.IDs,
!areaScope.Restrict, areaScope.IDs,
)
} else {
db = db.Where(`EXISTS (
SELECT 1
FROM product_warehouses pw
WHERE pw.product_id = products.id
AND pw.qty > 0
)`)
}
db = s.withRelations(db) db = s.withRelations(db, locationScope, areaScope)
if params.Search != "" { if params.Search != "" {
db = db.Where("products.name ILIKE ?", "%"+params.Search+"%") db = db.Where("products.name ILIKE ?", "%"+params.Search+"%")
} }
@@ -86,7 +140,34 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
} }
func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) { func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) {
product, err := s.ProductRepository.GetByID(c.Context(), id, s.withRelations) locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.ProductRepository.DB())
if err != nil {
return nil, err
}
if locationScope.Restrict || areaScope.Restrict {
if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) {
return nil, fiber.NewError(fiber.StatusNotFound, "Product not found")
}
var count int64
if err := s.ProductRepository.DB().WithContext(c.Context()).
Table("product_warehouses pw").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("pw.product_id = ?", id).
Where("pw.qty > 0").
Where("(? OR w.location_id IN ?)", !locationScope.Restrict, locationScope.IDs).
Where("(? OR w.area_id IN ?)", !areaScope.Restrict, areaScope.IDs).
Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Product not found")
}
}
product, err := s.ProductRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db, locationScope, areaScope)
})
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") return nil, fiber.NewError(fiber.StatusNotFound, "Product not found")
} }
@@ -8,6 +8,7 @@ import (
service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response" "gitlab.com/mbugroup/lti-api.git/internal/response"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -24,12 +25,13 @@ func NewProductWarehouseController(productWarehouseService service.ProductWareho
func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { func (u *ProductWarehouseController) 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),
ProductId: uint(c.QueryInt("product_id", 0)), ProductId: uint(c.QueryInt("product_id", 0)),
WarehouseId: uint(c.QueryInt("warehouse_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)),
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, ""),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -7,6 +7,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations"
kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
@@ -53,6 +54,19 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
return nil, 0, err return nil, 0, err
} }
applyScope := true
if params.TransferContext == utils.TransferContextInventoryTransfer {
applyScope = !m.HasPermission(c, m.P_TransferCreateOne)
}
var scope m.ScopeFilter
var err error
if applyScope {
scope, err = m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
}
if params.ProductId > 0 { if params.ProductId > 0 {
isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId) isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId)
if err != nil { if err != nil {
@@ -90,6 +104,17 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
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 {
db = s.withRelations(db) db = s.withRelations(db)
db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id").
Where("w_scope.deleted_at IS NULL")
if applyScope {
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where("w_scope.location_id IN ?", scope.IDs)
}
}
if params.ProductId != 0 { if params.ProductId != 0 {
db = db.Where("product_id = ?", params.ProductId) db = db.Where("product_id = ?", params.ProductId)
} }
@@ -116,7 +141,33 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
} }
func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductWarehouse, error) { func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductWarehouse, error) {
productWarehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) applyScope := true
if c.Query(utils.TransferContextKey, "") == utils.TransferContextInventoryTransfer {
applyScope = !m.HasPermission(c, m.P_TransferCreateOne)
}
var scope m.ScopeFilter
var err error
if applyScope {
scope, err = m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, err
}
}
productWarehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id").
Where("w_scope.deleted_at IS NULL")
if applyScope {
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where("w_scope.location_id IN ?", scope.IDs)
}
}
return db
})
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found")
} }
@@ -13,10 +13,11 @@ 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"`
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"`
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"`
} }
@@ -9,12 +9,12 @@ import (
) )
type TransferRelationDTO struct { type TransferRelationDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
MovementNumber string `json:"movement_number"` MovementNumber string `json:"movement_number"`
TransferReason string `json:"transfer_reason"` TransferReason string `json:"transfer_reason"`
TransferDate string `json:"transfer_date"` TransferDate string `json:"transfer_date"`
SourceWarehouse *warehouseDTO.WarehouseRelationDTO `json:"source_warehouse,omitempty"` SourceWarehouse *warehouseDTO.WarehouseRelationDTO `json:"source_warehouse,omitempty"`
DestinationWarehouse *warehouseDTO.WarehouseRelationDTO `json:"destination_warehouse,omitempty"` DestinationWarehouse *warehouseDTO.WarehouseRelationDTO `json:"destination_warehouse,omitempty"`
} }
type ProductSimpleDTO struct { type ProductSimpleDTO struct {
@@ -51,16 +51,16 @@ type TransferDetailDTO struct {
} }
type TransferDetailItemDTO struct { type TransferDetailItemDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
Product ProductSimpleDTO `json:"product"` Product ProductSimpleDTO `json:"product"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"` // Biaya ekspedisi per item TransportPerItem *float64 `json:"transport_per_item,omitempty"` // Biaya ekspedisi per item
ExpeditionVendor *SupplierSimpleDTO `json:"expedition_vendor,omitempty"` // Vendor ekspedisi ExpeditionVendor *SupplierSimpleDTO `json:"expedition_vendor,omitempty"` // Vendor ekspedisi
} }
type TransferDeliveryDTO struct { type TransferDeliveryDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
Supplier SupplierSimpleDTO `json:"supplier"` Supplier *SupplierSimpleDTO `json:"supplier,omitempty"`
VehiclePlate string `json:"vehicle_plate"` VehiclePlate string `json:"vehicle_plate"`
DriverName string `json:"driver_name"` DriverName string `json:"driver_name"`
DocumentNumber string `json:"document_number"` DocumentNumber string `json:"document_number"`
@@ -115,7 +115,6 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
} }
if d.ExpenseNonstock != nil { if d.ExpenseNonstock != nil {
priceCopy := d.ExpenseNonstock.Price priceCopy := d.ExpenseNonstock.Price
detailDTO.TransportPerItem = &priceCopy detailDTO.TransportPerItem = &priceCopy
@@ -155,12 +154,17 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
} }
} }
deliveries = append(deliveries, TransferDeliveryDTO{ var supplier *SupplierSimpleDTO
Id: del.Id, if del.Supplier != nil {
Supplier: SupplierSimpleDTO{ supplier = &SupplierSimpleDTO{
Id: del.Supplier.Id, Id: del.Supplier.Id,
Name: del.Supplier.Name, Name: del.Supplier.Name,
}, }
}
deliveries = append(deliveries, TransferDeliveryDTO{
Id: del.Id,
Supplier: supplier,
VehiclePlate: del.VehiclePlate, VehiclePlate: del.VehiclePlate,
DriverName: del.DriverName, DriverName: del.DriverName,
DocumentNumber: del.DocumentNumber, DocumentNumber: del.DocumentNumber,
@@ -201,7 +205,6 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
} }
if d.ExpenseNonstock != nil { if d.ExpenseNonstock != nil {
priceCopy := d.ExpenseNonstock.Price priceCopy := d.ExpenseNonstock.Price
detailDTO.TransportPerItem = &priceCopy detailDTO.TransportPerItem = &priceCopy
@@ -241,12 +244,17 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
} }
} }
deliveries = append(deliveries, TransferDeliveryDTO{ var supplier *SupplierSimpleDTO
Id: del.Id, if del.Supplier != nil {
Supplier: SupplierSimpleDTO{ supplier = &SupplierSimpleDTO{
Id: del.Supplier.Id, Id: del.Supplier.Id,
Name: del.Supplier.Name, Name: del.Supplier.Name,
}, }
}
deliveries = append(deliveries, TransferDeliveryDTO{
Id: del.Id,
Supplier: supplier,
VehiclePlate: del.VehiclePlate, VehiclePlate: del.VehiclePlate,
DriverName: del.DriverName, DriverName: del.DriverName,
DocumentNumber: del.DocumentNumber, DocumentNumber: del.DocumentNumber,
@@ -94,10 +94,24 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
return nil, 0, err return nil, 0, err
} }
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs)
}
if params.Search != "" { if params.Search != "" {
searchTerm := "%" + strings.TrimSpace(params.Search) + "%" searchTerm := "%" + strings.TrimSpace(params.Search) + "%"
db = db.Joins("LEFT JOIN warehouses AS from_warehouses ON from_warehouses.id = stock_transfers.from_warehouse_id"). db = db.Joins("LEFT JOIN warehouses AS from_warehouses ON from_warehouses.id = stock_transfers.from_warehouse_id").
@@ -116,6 +130,28 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
} }
func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) {
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
if err != nil {
return nil, err
}
if scope.Restrict {
if len(scope.IDs) == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
var count int64
if err := s.StockTransferRepo.DB().WithContext(c.Context()).
Table("stock_transfers").
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
Where("stock_transfers.id = ?", id).
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
}
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db) return s.withRelations(db)
@@ -196,6 +232,24 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
} }
for _, delivery := range req.Deliveries { for _, delivery := range req.Deliveries {
if delivery.SupplierID == 0 {
continue
}
if delivery.VehiclePlate == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Vehicle plate wajib diisi ketika supplier dipilih")
}
if delivery.DriverName == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Driver name wajib diisi ketika supplier dipilih")
}
if delivery.DeliveryCost <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost harus lebih dari 0 ketika supplier dipilih")
}
if delivery.DeliveryCostPerItem <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost per item harus lebih dari 0 ketika supplier dipilih")
}
supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil) supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -310,9 +364,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
var deliveries []*entity.StockTransferDelivery var deliveries []*entity.StockTransferDelivery
for _, delivery := range req.Deliveries { for _, delivery := range req.Deliveries {
supplierId := func() *uint64 {
if delivery.SupplierID > 0 {
id := uint64(delivery.SupplierID)
return &id
}
return nil
}()
deliveries = append(deliveries, &entity.StockTransferDelivery{ deliveries = append(deliveries, &entity.StockTransferDelivery{
StockTransferId: entityTransfer.Id, StockTransferId: entityTransfer.Id,
SupplierId: uint64(delivery.SupplierID), SupplierId: supplierId,
VehiclePlate: delivery.VehiclePlate, VehiclePlate: delivery.VehiclePlate,
DriverName: delivery.DriverName, DriverName: delivery.DriverName,
ShippingCostItem: delivery.DeliveryCostPerItem, ShippingCostItem: delivery.DeliveryCostPerItem,
@@ -415,6 +476,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
LoggableId: uint(detail.Id), LoggableId: uint(detail.Id),
Notes: "", Notes: "",
} }
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock -= latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
} }
@@ -451,6 +524,17 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
LoggableId: uint(detail.Id), LoggableId: uint(detail.Id),
Notes: "", Notes: "",
} }
stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
} }
@@ -458,6 +542,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if len(req.Deliveries) > 0 { if len(req.Deliveries) > 0 {
for _, delivery := range req.Deliveries { for _, delivery := range req.Deliveries {
// Skip adding to expensePayloads if SupplierID is 0 (optional)
if delivery.SupplierID == 0 {
continue
}
for _, prod := range delivery.Products { for _, prod := range delivery.Products {
detail := detailMap[uint64(prod.ProductID)] detail := detailMap[uint64(prod.ProductID)]
if detail == nil { if detail == nil {
@@ -21,12 +21,12 @@ type TransferDeliveryProduct struct {
} }
type TransferDelivery struct { type TransferDelivery struct {
DeliveryCost float64 `json:"delivery_cost" validate:"required"` DeliveryCost float64 `json:"delivery_cost"`
DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"` DeliveryCostPerItem float64 `json:"delivery_cost_per_item"`
DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"` DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"`
DriverName string `json:"driver_name" validate:"required"` DriverName string `json:"driver_name"`
VehiclePlate string `json:"vehicle_plate" validate:"required"` VehiclePlate string `json:"vehicle_plate"`
SupplierID uint `json:"supplier_id" validate:"required"` SupplierID uint `json:"supplier_id" `
Products []TransferDeliveryProduct `json:"products" validate:"required,dive"` Products []TransferDeliveryProduct `json:"products" validate:"required,dive"`
} }
+1 -1
View File
@@ -64,7 +64,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoService, warehouseRepo, projectFlockKandangRepo, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate) deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -14,13 +14,15 @@ import (
type MarketingDeliveryProductRepository interface { type MarketingDeliveryProductRepository interface {
repository.BaseRepository[entity.MarketingDeliveryProduct] repository.BaseRepository[entity.MarketingDeliveryProduct]
GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error)
GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error)
GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error)
GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error)
GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error)
UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error
GetUsageQty(ctx context.Context, id uint) (float64, error) GetUsageQty(ctx context.Context, id uint) (float64, error)
ResetFifoFields(ctx context.Context, id uint) error ResetFifoFields(ctx context.Context, id uint) error
GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
} }
type MarketingDeliveryProductRepositoryImpl struct { type MarketingDeliveryProductRepositoryImpl struct {
@@ -54,7 +56,85 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo
return deliveryProducts, nil return deliveryProducts, nil
} }
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) { func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("marketing_delivery_products.delivery_date IS NOT NULL").
Distinct("marketing_delivery_products.*")
if projectFlockKandangID != nil {
db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID)
}
db = db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Uom").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.Warehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Preload("MarketingProduct.Marketing").
Preload("MarketingProduct.Marketing.Customer").
Order("marketing_delivery_products.delivery_date DESC")
if err := db.Find(&deliveryProducts).Error; err != nil {
return nil, err
}
return deliveryProducts, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("flags.name IN (?)", []string{
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagPullet),
string(utils.FlagLayer),
}).
Where("marketing_delivery_products.delivery_date IS NOT NULL").
Distinct("marketing_delivery_products.*")
if projectFlockKandangID != nil {
db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID)
}
db = db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Order("marketing_delivery_products.delivery_date DESC")
if err := db.Find(&deliveryProducts).Error; err != nil {
return nil, err
}
return deliveryProducts, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
@@ -161,7 +241,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id"). Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id").
Where("marketing_delivery_products.delivery_date IS NOT NULL") Where("marketing_delivery_products.delivery_date IS NOT NULL")
if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.AreaId > 0 || filters.LocationId > 0 || filters.Search != "" || filters.MarketingType != "" { if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.AreaId > 0 || filters.LocationId > 0 || filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil || filters.Search != "" || filters.MarketingType != "" {
db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id")
} }
@@ -211,7 +291,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId) db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId)
} }
if filters.AreaId > 0 || filters.LocationId > 0 { if filters.AreaId > 0 || filters.LocationId > 0 || filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil {
db = db.Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). db = db.Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id") Joins("LEFT JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id")
@@ -222,6 +302,22 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
if filters.LocationId > 0 { if filters.LocationId > 0 {
db = db.Where("project_flocks.location_id = ?", filters.LocationId) db = db.Where("project_flocks.location_id = ?", filters.LocationId)
} }
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("project_flocks.area_id IN ?", filters.AllowedAreaIDs)
}
}
if filters.AllowedLocationIDs != nil {
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("project_flocks.location_id IN ?", filters.AllowedLocationIDs)
}
}
} }
if filters.MarketingType != "" { if filters.MarketingType != "" {
@@ -10,7 +10,6 @@ import (
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"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations"
@@ -71,6 +70,10 @@ func (s deliveryOrdersService) withRelations(db *gorm.DB) *gorm.DB {
} }
func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) { func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketingId uint) (*dto.MarketingDetailDTO, error) {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), marketingId); err != nil {
return nil, err
}
marketing, err := s.MarketingRepo.GetByID(c.Context(), marketingId, s.withRelations) marketing, err := s.MarketingRepo.GetByID(c.Context(), marketingId, s.withRelations)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing")
@@ -95,6 +98,11 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
return nil, 0, err return nil, 0, err
} }
scope, err := m.ResolveLocationScope(c, s.MarketingRepo.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
marketings, total, err := s.MarketingRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { marketings, total, err := s.MarketingRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
@@ -106,6 +114,23 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
Preload("Products.ProductWarehouse.Warehouse"). Preload("Products.ProductWarehouse.Warehouse").
Preload("Products.DeliveryProduct") Preload("Products.DeliveryProduct")
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where(
`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.location_id IN ?
)`,
scope.IDs,
)
}
if params.MarketingId != 0 { if params.MarketingId != 0 {
return db.Where("id = ?", params.MarketingId) return db.Where("id = ?", params.MarketingId)
} }
@@ -134,6 +159,9 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
} }
func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) { func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err
}
marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations) marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -173,6 +201,10 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
return nil, err return nil, err
} }
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), req.MarketingId); err != nil {
return nil, err
}
if err := commonSvc.EnsureRelations(c.Context(), if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Marketing", ID: &req.MarketingId, Exists: s.MarketingRepo.IdExists}, commonSvc.RelationCheck{Name: "Marketing", ID: &req.MarketingId, Exists: s.MarketingRepo.IdExists},
); err != nil { ); err != nil {
@@ -323,6 +355,10 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil, err return nil, err
} }
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err
}
if err := commonSvc.EnsureRelations(c.Context(), if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: s.MarketingRepo.IdExists}, commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: s.MarketingRepo.IdExists},
); err != nil { ); err != nil {
@@ -373,6 +409,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product")
} }
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
var itemDeliveryDate *time.Time var itemDeliveryDate *time.Time
if requestedProduct.DeliveryDate != "" { if requestedProduct.DeliveryDate != "" {
parsedDate, err := utils.ParseDateString(requestedProduct.DeliveryDate) parsedDate, err := utils.ParseDateString(requestedProduct.DeliveryDate)
@@ -384,11 +421,8 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
itemDeliveryDate = deliveryProduct.DeliveryDate itemDeliveryDate = deliveryProduct.DeliveryDate
} }
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
// Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false isPakanOrOVK := false
if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { if foundMarketingProduct.ProductWarehouse.Id != 0 && foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 {
for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags { for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true isPakanOrOVK = true
@@ -467,62 +501,39 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
Tx: tx, Tx: tx,
}) })
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err))
}
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
if err == nil && result.UsageQuantity > 0 { if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, 0); err != nil {
if actorID > 0 {
decreaseLog := &entity.StockLog{
Decrease: result.UsageQuantity,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: "",
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
}
if err != nil {
pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx)
pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil)
if err2 != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock")
}
if pw == nil || pw.Quantity < requestedQty {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 {
if pw != nil {
return pw.Quantity
} else {
return 0
}
}(), requestedQty))
}
if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
}
if actorID > 0 {
decreaseLog := &entity.StockLog{
Decrease: requestedQty,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: "",
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
return nil
}
if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
} }
if actorID > 0 && result.UsageQuantity > 0 {
decreaseLog := &entity.StockLog{
Decrease: result.UsageQuantity,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity),
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
decreaseLog.Stock = latestStockLog.Stock - decreaseLog.Decrease
} else {
decreaseLog.Stock -= decreaseLog.Decrease
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
return nil return nil
} }
@@ -553,6 +564,10 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
return err return err
} }
if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil {
return err
}
if actorID > 0 && currentUsage > 0 { if actorID > 0 && currentUsage > 0 {
increaseLog := &entity.StockLog{ increaseLog := &entity.StockLog{
Increase: currentUsage, Increase: currentUsage,
@@ -560,13 +575,20 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
LoggableId: deliveryProduct.Id, LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId, ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID, CreatedBy: actorID,
Notes: "", Notes: fmt.Sprintf("Release delivery stock (%.2f)", currentUsage),
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
increaseLog.Stock = latestStockLog.Stock + increaseLog.Increase
} else {
increaseLog.Stock += increaseLog.Increase
} }
s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil)
}
if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil)
return err
} }
return nil return nil
@@ -19,6 +19,7 @@ import (
userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -41,11 +42,12 @@ type salesOrdersService struct {
ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository
UserRepo userRepo.UserRepository UserRepo userRepo.UserRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
WarehouseRepo warehouseRepo.WarehouseRepository WarehouseRepo warehouseRepo.WarehouseRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
} }
func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo warehouseRepo.WarehouseRepository, func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, warehouseRepo warehouseRepo.WarehouseRepository,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService { projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService {
return &salesOrdersService{ return &salesOrdersService{
Log: utils.Log, Log: utils.Log,
@@ -55,6 +57,7 @@ func NewSalesOrdersService(marketingRepo repository.MarketingRepository, custome
ProductWarehouseRepo: productWarehouseRepo, ProductWarehouseRepo: productWarehouseRepo,
UserRepo: userRepo, UserRepo: userRepo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoSvc: fifoSvc,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
} }
@@ -70,6 +73,10 @@ func (s salesOrdersService) withRelations(db *gorm.DB) *gorm.DB {
} }
func (s salesOrdersService) getOne(c *fiber.Ctx, id uint) (*entity.Marketing, error) { func (s salesOrdersService) getOne(c *fiber.Ctx, id uint) (*entity.Marketing, error) {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err
}
marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations) marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "SalesOrders not found") return nil, fiber.NewError(fiber.StatusNotFound, "SalesOrders not found")
@@ -108,6 +115,9 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
} }
for _, item := range req.MarketingProducts { for _, item := range req.MarketingProducts {
if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil {
return nil, err
}
if err := commonSvc.EnsureRelations(c.Context(), if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists},
); err != nil { ); err != nil {
@@ -197,6 +207,10 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
return nil, err return nil, err
} }
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c) actorID, err := m.ActorIDFromContext(c)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -220,6 +234,9 @@ 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 err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil {
return nil, err
}
if err := commonSvc.EnsureRelations(c.Context(), if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists},
); err != nil { ); err != nil {
@@ -319,6 +336,32 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
totalPrice = totalWeight * rp.UnitPrice totalPrice = totalWeight * rp.UnitPrice
} }
deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check delivery product")
}
if err == nil && deliveryProduct.Id != 0 {
oldQty := old.Qty
newQty := rp.Qty
qtyDiff := newQty - oldQty
if qtyDiff < 0 {
return fiber.NewError(fiber.StatusBadRequest, "Cannot decrease quantity after stock has been allocated. Please delete and create new product.")
} else if qtyDiff > 0 {
_, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: deliveryProduct.Id,
ProductWarehouseID: rp.ProductWarehouseId,
Quantity: qtyDiff,
Tx: dbTransaction,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Insufficient stock for additional quantity: %v", err))
}
}
}
updateBody := map[string]any{ updateBody := map[string]any{
"product_warehouse_id": rp.ProductWarehouseId, "product_warehouse_id": rp.ProductWarehouseId,
"qty": rp.Qty, "qty": rp.Qty,
@@ -331,25 +374,20 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product")
} }
if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { if deliveryProduct.Id == 0 {
if errors.Is(err, gorm.ErrRecordNotFound) { mdp := &entity.MarketingDeliveryProduct{
MarketingProductId: old.Id,
mdp := &entity.MarketingDeliveryProduct{ UnitPrice: 0,
MarketingProductId: old.Id, TotalWeight: 0,
UnitPrice: 0, AvgWeight: 0,
TotalWeight: 0, TotalPrice: 0,
AvgWeight: 0, DeliveryDate: nil,
TotalPrice: 0, VehicleNumber: rp.VehicleNumber,
DeliveryDate: nil, UsageQty: 0,
VehicleNumber: rp.VehicleNumber, PendingQty: 0,
UsageQty: 0, }
PendingQty: 0, if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil {
} return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product")
if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product")
}
} else {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check delivery product")
} }
} }
} else { } else {
@@ -366,10 +404,18 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
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")
} }
if err == nil { if err == nil && deliveryProduct.Id != 0 {
if deliveryProduct.DeliveryDate != nil || deliveryProduct.UsageQty > 0 || deliveryProduct.PendingQty > 0 { if deliveryProduct.DeliveryDate != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has been delivered", old.Id))
}
if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{
UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: deliveryProduct.Id,
Tx: dbTransaction,
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock: %v", err))
} }
if err := invDeliveryRepoTx.DeleteOne(c.Context(), deliveryProduct.Id); err != nil { if err := invDeliveryRepoTx.DeleteOne(c.Context(), deliveryProduct.Id); err != nil {
@@ -414,6 +460,10 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
} }
func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return err
}
marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations) marketing, err := s.MarketingRepo.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -441,6 +491,19 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error {
marketingRepoTx := repository.NewMarketingRepository(dbTransaction) marketingRepoTx := repository.NewMarketingRepository(dbTransaction)
if len(marketing.Products) > 0 { if len(marketing.Products) > 0 {
deliveryProducts, err := marketingDeliveryProductRepoTx.GetByMarketingId(c.Context(), marketing.Id)
if err == nil && len(deliveryProducts) > 0 {
for _, dp := range deliveryProducts {
if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{
UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: dp.Id,
Tx: dbTransaction,
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for delivery product %d: %v", dp.Id, err))
}
}
}
for _, product := range marketing.Products { for _, product := range marketing.Products {
if err := marketingDeliveryProductRepoTx.DeleteMany(c.Context(), func(db *gorm.DB) *gorm.DB { if err := marketingDeliveryProductRepoTx.DeleteMany(c.Context(), func(db *gorm.DB) *gorm.DB {
return db.Where("marketing_product_id = ?", product.Id).Unscoped() return db.Where("marketing_product_id = ?", product.Id).Unscoped()
@@ -478,6 +541,12 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
return nil, err return nil, err
} }
for _, id := range req.ApprovableIds {
if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil {
return nil, err
}
}
actorID, err := m.ActorIDFromContext(c) actorID, err := m.ActorIDFromContext(c)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -47,16 +47,22 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar
return nil, 0, err return nil, 0, err
} }
var scopeErr error
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
areas, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { areas, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db, scopeErr = m.ApplyAreaScope(c, db, "id")
if params.Search != "" { if params.Search != "" {
return db.Where("name ILIKE ?", "%"+params.Search+"%") return db.Where("name ILIKE ?", "%"+params.Search+"%")
} }
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
if scopeErr != nil {
return nil, 0, scopeErr
}
if err != nil { if err != nil {
s.Log.Errorf("Failed to get areas: %+v", err) s.Log.Errorf("Failed to get areas: %+v", err)
return nil, 0, err return nil, 0, err
@@ -65,7 +71,16 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar
} }
func (s areaService) GetOne(c *fiber.Ctx, id uint) (*entity.Area, error) { func (s areaService) GetOne(c *fiber.Ctx, id uint) (*entity.Area, error) {
area, err := s.Repository.GetByID(c.Context(), id, s.withRelations) var scopeErr error
area, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db, scopeErr = m.ApplyAreaScope(c, db, "id")
return db
})
if scopeErr != nil {
return nil, scopeErr
}
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Area not found") return nil, fiber.NewError(fiber.StatusNotFound, "Area not found")
} }
@@ -5,6 +5,7 @@ import (
"strings" "strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -43,6 +44,61 @@ func (s employeesService) withRelations(db *gorm.DB) *gorm.DB {
Where("employees.deleted_at IS NULL") Where("employees.deleted_at IS NULL")
} }
func (s employeesService) ensureEmployeeAccess(c *fiber.Ctx, employeeID uint) error {
if employeeID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("employees e").
Joins("JOIN employee_kandangs ek ON ek.employee_id = e.id").
Joins("JOIN kandangs k ON k.id = ek.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("e.id = ?", employeeID).
Where("e.deleted_at IS NULL")
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Employees not found")
}
return nil
}
func (s employeesService) ensureKandangIDsAccess(c *fiber.Ctx, kandangIDs []uint) error {
if len(kandangIDs) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("kandangs k").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("k.id IN ?", kandangIDs)
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count != int64(len(kandangIDs)) {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
return nil
}
func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Employees, int64, error) { func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Employees, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, err return nil, 0, err
@@ -52,17 +108,29 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
employeess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { employeess, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db = db.Joins("JOIN employee_kandangs ek ON ek.employee_id = employees.id").
Joins("JOIN kandangs k ON k.id = ek.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id")
var scopeErr error
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if scopeErr != nil {
return db.Where("1 = 0")
}
if params.Search != "" { if params.Search != "" {
db = db.Where("employees.name ILIKE ?", "%"+params.Search+"%") db = db.Where("employees.name ILIKE ?", "%"+params.Search+"%")
} }
if params.KandangId != nil { if params.KandangId != nil {
db = db.Joins("JOIN employee_kandangs ek ON ek.employee_id = employees.id"). db = db.Where("ek.kandang_id = ?", *params.KandangId)
Where("ek.kandang_id = ?", *params.KandangId)
} }
if params.IsActive != nil { if params.IsActive != nil {
db = db.Where("employees.is_active = ?", *params.IsActive) db = db.Where("employees.is_active = ?", *params.IsActive)
} }
return db.Order("employees.created_at DESC").Order("employees.updated_at DESC") return db.
Select("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at").
Group("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at").
Order("employees.created_at DESC").
Order("employees.updated_at DESC")
}) })
if err != nil { if err != nil {
@@ -73,6 +141,9 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
} }
func (s employeesService) GetOne(c *fiber.Ctx, id uint) (*entity.Employees, error) { func (s employeesService) GetOne(c *fiber.Ctx, id uint) (*entity.Employees, error) {
if err := s.ensureEmployeeAccess(c, id); err != nil {
return nil, err
}
employees, err := s.Repository.GetByID(c.Context(), id, s.withRelations) employees, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Employees not found") return nil, fiber.NewError(fiber.StatusNotFound, "Employees not found")
@@ -98,6 +169,9 @@ func (s *employeesService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if len(kandangIDs) == 0 { if len(kandangIDs) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id") return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id")
} }
if err := s.ensureKandangIDsAccess(c, kandangIDs); err != nil {
return nil, err
}
if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB {
return db.Where("LOWER(name) = ?", strings.ToLower(name)) return db.Where("LOWER(name) = ?", strings.ToLower(name))
@@ -147,6 +221,9 @@ func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := s.ensureEmployeeAccess(c, id); err != nil {
return nil, err
}
updateBody := make(map[string]any) updateBody := make(map[string]any)
var ( var (
@@ -181,6 +258,9 @@ func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if len(ids) == 0 { if len(ids) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id") return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id")
} }
if err := s.ensureKandangIDsAccess(c, ids); err != nil {
return nil, err
}
kandangIDs = ids kandangIDs = ids
needKandangUpdate = true needKandangUpdate = true
@@ -234,6 +314,9 @@ func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
func (s employeesService) DeleteOne(c *fiber.Ctx, id uint) error { func (s employeesService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureEmployeeAccess(c, id); err != nil {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Employees not found") return fiber.NewError(fiber.StatusNotFound, "Employees not found")
@@ -49,10 +49,13 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
return nil, 0, err return nil, 0, err
} }
var scopeErr error
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
kandangs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { kandangs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db, scopeErr = m.ApplyLocationScope(c, db, "kandangs.location_id")
if params.Search != "" { if params.Search != "" {
return db.Where("name ILIKE ?", "%"+params.Search+"%") return db.Where("name ILIKE ?", "%"+params.Search+"%")
} }
@@ -65,6 +68,9 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
if scopeErr != nil {
return nil, 0, scopeErr
}
if err != nil { if err != nil {
s.Log.Errorf("Failed to get kandangs: %+v", err) s.Log.Errorf("Failed to get kandangs: %+v", err)
return nil, 0, err return nil, 0, err
@@ -73,7 +79,16 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
} }
func (s kandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Kandang, error) { func (s kandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Kandang, error) {
kandang, err := s.Repository.GetByID(c.Context(), id, s.withRelations) var scopeErr error
kandang, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db, scopeErr = m.ApplyLocationScope(c, db, "kandangs.location_id")
return db
})
if scopeErr != nil {
return nil, scopeErr
}
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found")
} }
@@ -88,6 +103,9 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := m.EnsureLocationAccess(c, s.Repository.DB(), req.LocationId); err != nil {
return nil, err
}
if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil {
s.Log.Errorf("Failed to check kandang name: %+v", err) s.Log.Errorf("Failed to check kandang name: %+v", err)
@@ -162,6 +180,14 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := m.EnsureKandangAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
if req.LocationId != nil {
if err := m.EnsureLocationAccess(c, s.Repository.DB(), *req.LocationId); err != nil {
return nil, err
}
}
existing, err := s.Repository.GetByID(c.Context(), id, nil) existing, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -253,6 +279,10 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
} }
func (s kandangService) DeleteOne(c *fiber.Ctx, id uint) error { func (s kandangService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := m.EnsureKandangAccess(c, s.Repository.DB(), id); err != nil {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found") return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
@@ -47,10 +47,13 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
return nil, 0, err return nil, 0, err
} }
var scopeErr error
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
locations, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { locations, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db, scopeErr = m.ApplyLocationScope(c, db, "locations.id")
if params.Search != "" { if params.Search != "" {
db = db.Where("name ILIKE ?", "%"+params.Search+"%") db = db.Where("name ILIKE ?", "%"+params.Search+"%")
} }
@@ -60,6 +63,9 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
if scopeErr != nil {
return nil, 0, scopeErr
}
if err != nil { if err != nil {
s.Log.Errorf("Failed to get locations: %+v", err) s.Log.Errorf("Failed to get locations: %+v", err)
return nil, 0, err return nil, 0, err
@@ -68,7 +74,16 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
} }
func (s locationService) GetOne(c *fiber.Ctx, id uint) (*entity.Location, error) { func (s locationService) GetOne(c *fiber.Ctx, id uint) (*entity.Location, error) {
location, err := s.Repository.GetByID(c.Context(), id, s.withRelations) var scopeErr error
location, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db, scopeErr = m.ApplyLocationScope(c, db, "locations.id")
return db
})
if scopeErr != nil {
return nil, scopeErr
}
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Location not found") return nil, fiber.NewError(fiber.StatusNotFound, "Location not found")
} }
@@ -3,11 +3,13 @@ package controller
import ( import (
"math" "math"
"strconv" "strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response" "gitlab.com/mbugroup/lti-api.git/internal/response"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -23,6 +25,11 @@ func NewWarehouseController(warehouseService service.WarehouseService) *Warehous
} }
func (u *WarehouseController) GetAll(c *fiber.Ctx) error { func (u *WarehouseController) GetAll(c *fiber.Ctx) error {
excludeIDs, err := parseCommaSeparatedUint(c.Query("exclude_id", ""))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.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),
@@ -30,6 +37,8 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error {
AreaId: c.QueryInt("area_id", 0), AreaId: c.QueryInt("area_id", 0),
LocationId: c.QueryInt("location_id", 0), LocationId: c.QueryInt("location_id", 0),
ActiveProjectFlockOnly: c.QueryBool("active_project_flock", false), ActiveProjectFlockOnly: c.QueryBool("active_project_flock", false),
TransferContext: c.Query(utils.TransferContextKey, ""),
ExcludeIDs: excludeIDs,
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -56,6 +65,28 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error {
}) })
} }
func parseCommaSeparatedUint(raw string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
out := make([]uint, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid exclude_id")
}
out = append(out, uint(value))
}
return out, nil
}
func (u *WarehouseController) GetOne(c *fiber.Ctx) error { func (u *WarehouseController) GetOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -48,10 +48,20 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return nil, 0, err return nil, 0, err
} }
var scopeErr error
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
applyScope := true
if params.TransferContext == utils.TransferContextInventoryTransfer {
applyScope = !m.HasPermission(c, m.P_TransferCreateOne)
}
if applyScope {
db, scopeErr = m.ApplyLocationAreaScope(c, db, "warehouses.location_id", "warehouses.area_id")
}
if params.Search != "" { if params.Search != "" {
db = db.Where("warehouses.name ILIKE ?", "%"+params.Search+"%") db = db.Where("warehouses.name ILIKE ?", "%"+params.Search+"%")
} }
@@ -78,9 +88,15 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
) )
`, "Aktif") `, "Aktif")
} }
if len(params.ExcludeIDs) > 0 {
db = db.Where("warehouses.id NOT IN ?", params.ExcludeIDs)
}
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
if scopeErr != nil {
return nil, 0, scopeErr
}
if err != nil { if err != nil {
s.Log.Errorf("Failed to get warehouses: %+v", err) s.Log.Errorf("Failed to get warehouses: %+v", err)
return nil, 0, err return nil, 0, err
@@ -89,7 +105,16 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
} }
func (s warehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.Warehouse, error) { func (s warehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.Warehouse, error) {
warehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) var scopeErr error
warehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db, scopeErr = m.ApplyLocationAreaScope(c, db, "warehouses.location_id", "warehouses.area_id")
return db
})
if scopeErr != nil {
return nil, scopeErr
}
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
} }
@@ -120,6 +145,19 @@ func (s *warehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if err := validateWarehouseTypeRequirements(typ, &req.AreaId, &req.LocationId, &req.KandangId, createValidationOpts); err != nil { if err := validateWarehouseTypeRequirements(typ, &req.AreaId, &req.LocationId, &req.KandangId, createValidationOpts); err != nil {
return nil, err return nil, err
} }
if err := m.EnsureAreaAccess(c, s.Repository.DB(), req.AreaId); err != nil {
return nil, err
}
if req.LocationId != nil {
if err := m.EnsureLocationAccess(c, s.Repository.DB(), *req.LocationId); err != nil {
return nil, err
}
}
if req.KandangId != nil {
if err := m.EnsureKandangAccess(c, s.Repository.DB(), *req.KandangId); err != nil {
return nil, err
}
}
//? Check relation area, location, and kandang //? Check relation area, location, and kandang
if err := common.EnsureRelations(c.Context(), if err := common.EnsureRelations(c.Context(),
@@ -158,6 +196,21 @@ func (s warehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if req.AreaId != nil {
if err := m.EnsureAreaAccess(c, s.Repository.DB(), *req.AreaId); err != nil {
return nil, err
}
}
if req.LocationId != nil {
if err := m.EnsureLocationAccess(c, s.Repository.DB(), *req.LocationId); err != nil {
return nil, err
}
}
if req.KandangId != nil {
if err := m.EnsureKandangAccess(c, s.Repository.DB(), *req.KandangId); err != nil {
return nil, err
}
}
existing, err := s.GetOne(c, id) existing, err := s.GetOne(c, id)
if err != nil { if err != nil {
@@ -248,6 +301,10 @@ func (s warehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
func (s warehouseService) DeleteOne(c *fiber.Ctx, id uint) error { func (s warehouseService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := m.EnsureWarehouseAccess(c, s.Repository.DB(), id); err != nil {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Warehouse not found") return fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
@@ -23,4 +23,6 @@ type Query struct {
AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` AreaId int `query:"area_id" validate:"omitempty,number,gt=0"`
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"` LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
ActiveProjectFlockOnly bool `query:"active_project_flock"` ActiveProjectFlockOnly bool `query:"active_project_flock"`
ExcludeIDs []uint `query:"-" validate:"omitempty,dive,gt=0"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
} }
@@ -645,6 +645,18 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB,
CreatedBy: actorID, CreatedBy: actorID,
Notes: fmt.Sprintf("Chickin #%d", chickin.Id), Notes: fmt.Sprintf("Chickin #%d", chickin.Id),
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
decreaseLog.Stock = latestStockLog.Stock
decreaseLog.Stock -= decreaseLog.Decrease
} else {
decreaseLog.Stock -= decreaseLog.Decrease
}
s.StockLogRepo.CreateOne(ctx, decreaseLog, nil) s.StockLogRepo.CreateOne(ctx, decreaseLog, nil)
} }
@@ -701,6 +713,18 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
CreatedBy: actorID, CreatedBy: actorID,
Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id),
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
increaseLog.Stock = latestStockLog.Stock
increaseLog.Stock += increaseLog.Increase
} else {
increaseLog.Stock += increaseLog.Increase
}
s.StockLogRepo.CreateOne(ctx, increaseLog, nil) s.StockLogRepo.CreateOne(ctx, increaseLog, nil)
} }
@@ -88,9 +88,14 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer
return nil, 0, err return nil, 0, err
} }
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
projectFlockKandangs, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) projectFlockKandangs, total, err := s.Repository.GetAllWithFiltersScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict)
if err != nil { if err != nil {
s.Log.Errorf("Failed to get projectFlockKandangs: %+v", err) s.Log.Errorf("Failed to get projectFlockKandangs: %+v", err)
@@ -106,6 +111,28 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer
} }
func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) { func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) {
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, nil, nil, err
}
if scope.Restrict {
if len(scope.IDs) == 0 {
return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found")
}
var count int64
if err := s.Repository.DB().WithContext(c.Context()).
Table("project_flock_kandangs").
Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id").
Where("project_flock_kandangs.id = ?", id).
Where("project_flocks.location_id IN ?", scope.IDs).
Count(&count).Error; err != nil {
return nil, nil, nil, err
}
if count == 0 {
return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found")
}
}
projectFlockKandang, err := s.Repository.GetByID(c.Context(), id) projectFlockKandang, err := s.Repository.GetByID(c.Context(), id)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found")
@@ -12,6 +12,7 @@ import (
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response" "gitlab.com/mbugroup/lti-api.git/internal/response"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -76,6 +77,7 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error {
query.Category = category query.Category = category
} }
query.TransferContext = c.Query(utils.TransferContextKey, "")
if kandangRaw := c.Query("kandang_id", c.Query("kandang_ids", "")); kandangRaw != "" { if kandangRaw := c.Query("kandang_id", c.Query("kandang_ids", "")); kandangRaw != "" {
ids, err := parseUintList(kandangRaw) ids, err := parseUintList(kandangRaw)
@@ -14,6 +14,7 @@ import (
type ProjectflockRepository interface { type ProjectflockRepository interface {
repository.BaseRepository[entity.ProjectFlock] repository.BaseRepository[entity.ProjectFlock]
GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error)
GetAllWithFiltersScoped(ctx context.Context, offset, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]entity.ProjectFlock, int64, error)
WithDefaultRelations() func(*gorm.DB) *gorm.DB WithDefaultRelations() func(*gorm.DB) *gorm.DB
ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error)
GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error) GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error)
@@ -48,6 +49,19 @@ func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offs
}) })
} }
func (r *ProjectflockRepositoryImpl) GetAllWithFiltersScoped(ctx context.Context, offset, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]entity.ProjectFlock, int64, error) {
return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB {
db = r.applyQueryFilters(r.WithDefaultRelations()(db), params)
if restrict {
if len(locationIDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where("project_flocks.location_id IN ?", locationIDs)
}
return db
})
}
func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB {
return db. return db.
@@ -20,6 +20,7 @@ type ProjectFlockKandangRepository interface {
DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error
GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error)
GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error)
GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error)
GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error)
ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error)
HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error)
@@ -197,6 +198,104 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex
return records, total, nil return records, total, nil
} }
func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error) {
var records []entity.ProjectFlockKandang
var total int64
query, ok := params.(*validation.Query)
q := r.db.WithContext(ctx).
Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\"").
Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\"").
Preload("ProjectFlock").
Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location").
Preload("ProjectFlock.CreatedUser").
Preload("ProjectFlock.Kandangs").
Preload("ProjectFlock.KandangHistory").
Preload("Kandang").
Preload("Chickins").
Preload("Chickins.CreatedUser").
Preload("Chickins.ProductWarehouse")
if restrict {
if len(locationIDs) == 0 {
return []entity.ProjectFlockKandang{}, 0, nil
}
q = q.Where("\"project_flocks\".\"location_id\" IN ?", locationIDs)
}
if ok && query != nil && query.StepName != "" {
q = q.Where(`
EXISTS (
SELECT 1 FROM "approvals"
WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id"
AND "approvals"."approvable_type" = ?
AND LOWER("approvals"."step_name") = LOWER(?)
AND "approvals"."id" IN (
SELECT "approvals"."id" FROM "approvals"
WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id"
AND "approvals"."approvable_type" = ?
ORDER BY "approvals"."id" DESC
LIMIT 1
)
)
`, "PROJECT_FLOCK_KANDANGS", query.StepName, "PROJECT_FLOCK_KANDANGS")
}
if ok && query != nil {
if query.Search != "" {
escapedSearch := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(query.Search)
q = q.Where(
r.db.Where("LOWER(\"kandangs\".\"name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%").
Or("LOWER(\"project_flocks\".\"flock_name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"),
)
}
if query.ProjectFlockId > 0 {
q = q.Where("\"project_flock_kandangs\".\"project_flock_id\" = ?", query.ProjectFlockId)
}
if query.KandangId > 0 {
q = q.Where("\"project_flock_kandangs\".\"kandang_id\" = ?", query.KandangId)
}
if query.Category != "" {
q = q.Where("\"project_flocks\".\"category\" = ?", query.Category)
}
if query.AreaId > 0 {
q = q.Where("\"project_flocks\".\"area_id\" = ?", query.AreaId)
}
}
if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil {
return nil, 0, err
}
sortBy := "\"project_flock_kandangs\".\"created_at\" DESC"
if ok && query != nil && query.SortBy != "" {
sortOrder := "DESC"
if query.SortOrder == "ASC" {
sortOrder = "ASC"
}
switch query.SortBy {
case "created_at":
sortBy = "\"project_flock_kandangs\".\"created_at\" " + sortOrder
case "period":
sortBy = "\"project_flocks\".\"period\" " + sortOrder
}
}
if err := q.Order(sortBy).Offset(offset).Limit(limit).Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}
func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository { func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository {
return &projectFlockKandangRepositoryImpl{db: tx} return &projectFlockKandangRepositoryImpl{db: tx}
} }
@@ -117,9 +117,20 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
return nil, 0, nil, err return nil, 0, nil, err
} }
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, nil, err
}
if params.TransferContext == utils.TransferContextTransferToLaying {
if m.HasPermission(c, m.P_TransferToLaying_CreateOne) || m.HasPermission(c, m.P_TransferToLaying_UpdateOne) {
scope.Restrict = false
scope.IDs = nil
}
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
projectflocks, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) projectflocks, total, err := s.Repository.GetAllWithFiltersScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict)
if err != nil { if err != nil {
s.Log.Errorf("Failed to get projectflocks: %+v", err) s.Log.Errorf("Failed to get projectflocks: %+v", err)
@@ -193,7 +204,16 @@ func (s projectflockService) getOneEntityOnly(c *fiber.Ctx, id uint) (*entity.Pr
} }
func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) { func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) {
projectflock, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, nil, err
}
projectflock, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.Repository.WithDefaultRelations()(db)
db = m.ApplyScopeFilter(db, scope, "project_flocks.location_id")
return db
})
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") return nil, nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found")
} }
@@ -12,16 +12,17 @@ type Create 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"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
SortBy string `query:"sort_by" validate:"omitempty"` SortBy string `query:"sort_by" validate:"omitempty"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
AreaId uint `query:"area_id" validate:"omitempty,number,gt=0"` AreaId uint `query:"area_id" validate:"omitempty,number,gt=0"`
LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"`
Period int `query:"period" validate:"omitempty,number,gt=0"` Period int `query:"period" validate:"omitempty,number,gt=0"`
Category string `query:"category" validate:"omitempty"` Category string `query:"category" validate:"omitempty"`
KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=transfer_to_laying"`
} }
type Approve struct { type Approve struct {
@@ -4,6 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math"
"strings"
"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"
@@ -19,9 +23,6 @@ import (
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
"math"
"strings"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -108,6 +109,13 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return nil, 0, err return nil, 0, err
} }
var scopeErr error
if params.ProjectFlockKandangId != 0 {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, params.ProjectFlockKandangId); err != nil {
return nil, 0, err
}
}
limit := params.Limit limit := params.Limit
if limit == 0 { if limit == 0 {
limit = 10 limit = 10
@@ -120,6 +128,10 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB { recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB {
db = s.Repository.WithRelations(db) db = s.Repository.WithRelations(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")
db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id")
if params.ProjectFlockKandangId != 0 { if params.ProjectFlockKandangId != 0 {
db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId) db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId)
} }
@@ -127,6 +139,9 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC") return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
}) })
if scopeErr != nil {
return nil, 0, scopeErr
}
if err != nil { if err != nil {
s.Log.Errorf("Failed to get recordings: %+v", err) s.Log.Errorf("Failed to get recordings: %+v", err)
return nil, 0, err return nil, 0, err
@@ -141,6 +156,10 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
} }
func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) {
if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
recording, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { recording, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.Repository.WithRelations(db) return s.Repository.WithRelations(db)
}) })
@@ -164,6 +183,9 @@ func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint, r
if projectFlockKandangId == 0 { if projectFlockKandangId == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
} }
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, projectFlockKandangId); err != nil {
return 0, err
}
if recordTime.IsZero() { if recordTime.IsZero() {
recordTime = time.Now().UTC() recordTime = time.Now().UTC()
@@ -181,6 +203,9 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, req.ProjectFlockKandangId); err != nil {
return nil, err
}
ctx := c.Context() ctx := c.Context()
recordTime := time.Now().UTC() recordTime := time.Now().UTC()
@@ -352,6 +377,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
if req.Stocks == nil && req.Depletions == nil && req.Eggs == nil { if req.Stocks == nil && req.Depletions == nil && req.Eggs == nil {
return s.GetOne(c, id) return s.GetOne(c, id)
@@ -508,6 +536,17 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { if egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue continue
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
latestStockLog := &entity.StockLog{}
if len(stockLogs) > 0 {
latestStockLog = stockLogs[0]
} else {
latestStockLog.Stock = 0
}
logs = append(logs, &entity.StockLog{ logs = append(logs, &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId, ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID, CreatedBy: actorID,
@@ -515,6 +554,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
LoggableType: string(utils.StockLogTypeRecording), LoggableType: string(utils.StockLogTypeRecording),
LoggableId: recordingEntity.Id, LoggableId: recordingEntity.Id,
Notes: note, Notes: note,
Stock: latestStockLog.Stock - float64(egg.Qty),
}) })
} }
if len(logs) > 0 { if len(logs) > 0 {
@@ -633,6 +673,11 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
for _, id := range req.ApprovableIds {
if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
}
actionValue := strings.ToUpper(strings.TrimSpace(req.Action)) actionValue := strings.ToUpper(strings.TrimSpace(req.Action))
var action entity.ApprovalAction var action entity.ApprovalAction
@@ -710,7 +755,15 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent
} }
func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil {
return err
}
ctx := c.Context() ctx := c.Context()
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return err
}
note := fmt.Sprintf("Recording-Delete#%d", id)
return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
oldDepletions, err := s.Repository.ListDepletions(tx, id) oldDepletions, err := s.Repository.ListDepletions(tx, id)
@@ -719,7 +772,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err return err
} }
if s.FifoSvc != nil { if s.FifoSvc != nil {
if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, "", 0); err != nil { if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil {
return err return err
} }
} }
@@ -741,7 +794,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err return err
} }
if err := s.releaseRecordingStocks(ctx, tx, oldStocks, "", 0); err != nil { if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil {
return err return err
} }
@@ -749,6 +802,10 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err return err
} }
if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil {
return err
}
if err := s.Repository.WithTx(tx).DeleteOne(ctx, id); err != nil { if err := s.Repository.WithTx(tx).DeleteOne(ctx, id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Recording not found") return fiber.NewError(fiber.StatusNotFound, "Recording not found")
@@ -761,6 +818,40 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
}) })
} }
func (s *recordingService) logRecordingEggRollback(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
note string,
actorID uint,
) error {
if len(eggs) == 0 || s.StockLogRepo == nil {
return nil
}
if strings.TrimSpace(note) == "" || actorID == 0 {
return nil
}
for _, egg := range eggs {
if egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
log := &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Decrease: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: egg.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
return nil
}
// === Persistence Helpers === // === Persistence Helpers ===
func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error {
@@ -859,6 +950,18 @@ func (s *recordingService) consumeRecordingStocks(
LoggableId: stock.RecordingId, LoggableId: stock.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -926,6 +1029,18 @@ func (s *recordingService) consumeRecordingDepletions(
LoggableId: depletion.RecordingId, LoggableId: depletion.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -944,6 +1059,18 @@ func (s *recordingService) consumeRecordingDepletions(
LoggableId: depletion.RecordingId, LoggableId: depletion.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1004,6 +1131,18 @@ func (s *recordingService) releaseRecordingStocks(
LoggableId: stock.RecordingId, LoggableId: stock.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1066,6 +1205,18 @@ func (s *recordingService) releaseRecordingDepletions(
LoggableId: depletion.RecordingId, LoggableId: depletion.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1084,6 +1235,18 @@ func (s *recordingService) releaseRecordingDepletions(
LoggableId: depletion.RecordingId, LoggableId: depletion.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1231,6 +1394,18 @@ func (s *recordingService) replenishRecordingEggs(
LoggableId: egg.RecordingId, LoggableId: egg.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1402,6 +1577,7 @@ func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
} }
func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool {
existingUsage := make(map[uint]float64) existingUsage := make(map[uint]float64)
for _, stock := range existing { for _, stock := range existing {
var usage float64 var usage float64
@@ -1613,7 +1789,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
var eggMass float64 var eggMass float64
if remainingChick > 0 && totalEggWeightGrams > 0 { if remainingChick > 0 && totalEggWeightGrams > 0 {
eggMass = (totalEggWeightGrams / remainingChick) / 1000 // totalEggWeightGrams is in grams; egg mass is grams per hen.
eggMass = totalEggWeightGrams / remainingChick
updates["egg_mass"] = eggMass updates["egg_mass"] = eggMass
recording.EggMass = &eggMass recording.EggMass = &eggMass
} else { } else {
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
@@ -16,6 +17,10 @@ type TransferLayingRepository interface {
// Tambah method baru untuk query dengan filter lengkap // Tambah method baru untuk query dengan filter lengkap
GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error)
// Get sequence for movement number
GetNextMovementNumber(ctx context.Context) (int64, error)
GenerateMovementNumber(ctx context.Context) (string, error)
} }
type TransferLayingRepositoryImpl struct { type TransferLayingRepositoryImpl struct {
@@ -29,6 +34,26 @@ func NewTransferLayingRepository(db *gorm.DB) TransferLayingRepository {
db: db, db: db,
} }
} }
func (r *TransferLayingRepositoryImpl) GetNextMovementNumber(ctx context.Context) (int64, error) {
var seq int64
err := r.db.WithContext(ctx).Raw("SELECT nextval('transfer_laying_seq')").Scan(&seq).Error
if err != nil {
return 0, err
}
return seq, nil
}
func (r *TransferLayingRepositoryImpl) GenerateMovementNumber(ctx context.Context) (string, error) {
seq, err := r.GetNextMovementNumber(ctx)
if err != nil {
return "", err
}
movementNumber := fmt.Sprintf("TL-%05d", seq)
return movementNumber, nil
}
func (r *TransferLayingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { func (r *TransferLayingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.LayingTransfer](ctx, r.db, id) return repository.Exists[entity.LayingTransfer](ctx, r.db, id)
} }
@@ -52,6 +77,8 @@ type GetAllFilterParams struct {
FlockSource []uint FlockSource []uint
FlockDestination []uint FlockDestination []uint
Status []string Status []string
LocationIDs []uint
LocationRestrict bool
} }
func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) { func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) {
@@ -110,6 +137,25 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of
} }
} }
if params.LocationRestrict {
if len(params.LocationIDs) == 0 {
q = q.Where("1 = 0")
} else {
q = q.Where(
`EXISTS (
SELECT 1 FROM project_flocks pf
WHERE pf.id = laying_transfers.from_project_flock_id
AND pf.location_id IN ?
) OR EXISTS (
SELECT 1 FROM project_flocks pf
WHERE pf.id = laying_transfers.to_project_flock_id
AND pf.location_id IN ?
)`,
params.LocationIDs, params.LocationIDs,
)
}
}
if err := q.Count(&total).Error; err != nil { if err := q.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math"
"strings" "strings"
"time" "time"
@@ -107,6 +108,11 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([
return nil, 0, err return nil, 0, err
} }
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
filterParams := &repository.GetAllFilterParams{ filterParams := &repository.GetAllFilterParams{
@@ -116,6 +122,8 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([
FlockSource: params.FlockSource, FlockSource: params.FlockSource,
FlockDestination: params.FlockDestination, FlockDestination: params.FlockDestination,
Status: params.Status, Status: params.Status,
LocationIDs: scope.IDs,
LocationRestrict: scope.Restrict,
} }
transferLayings, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, filterParams) transferLayings, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, filterParams)
@@ -124,11 +132,6 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([
return nil, 0, err return nil, 0, err
} }
if err != nil {
s.Log.Errorf("Failed to get transferLayings: %+v", err)
return nil, 0, err
}
approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB())
for i, transfer := range transferLayings { for i, transfer := range transferLayings {
latestApproval, err := approvalRepo.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, func(db *gorm.DB) *gorm.DB { latestApproval, err := approvalRepo.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, func(db *gorm.DB) *gorm.DB {
@@ -168,6 +171,11 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if !m.HasPermission(c, m.P_TransferToLaying_CreateOne) {
if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.TargetProjectFlockId); err != nil {
return nil, err
}
}
actorID, err := m.ActorIDFromContext(c) actorID, err := m.ActorIDFromContext(c)
if err != nil { if err != nil {
@@ -264,7 +272,11 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty))
} }
transferNumber := fmt.Sprintf("TL-%d", time.Now().UnixNano()) transferNumber, err := s.Repository.GenerateMovementNumber(c.Context())
if err != nil {
s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
}
createBody := &entity.LayingTransfer{ createBody := &entity.LayingTransfer{
TransferNumber: transferNumber, TransferNumber: transferNumber,
@@ -396,6 +408,11 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if !m.HasPermission(c, m.P_TransferToLaying_UpdateOne) {
if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), req.TargetProjectFlockId); err != nil {
return nil, err
}
}
existingTransfer, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { existingTransfer, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Preload("Sources.ProductWarehouse").Preload("Targets") return db.Preload("Sources.ProductWarehouse").Preload("Targets")
@@ -428,15 +445,105 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
return nil, fiber.NewError(fiber.StatusBadRequest, "Target project flock not found") return nil, fiber.NewError(fiber.StatusBadRequest, "Target project flock not found")
} }
sourceKandangIDs := make([]uint, len(req.SourceKandangs))
for i, detail := range req.SourceKandangs {
sourceKandangIDs[i] = detail.ProjectFlockKandangId
}
if err := s.validateKandangOwnership(
c.Context(),
req.SourceProjectFlockId,
sourceKandangIDs,
); err != nil {
return nil, err
}
targetKandangIDs := make([]uint, len(req.TargetKandangs))
for i, detail := range req.TargetKandangs {
targetKandangIDs[i] = detail.ProjectFlockKandangId
}
if err := s.validateKandangOwnership(
c.Context(),
req.TargetProjectFlockId,
targetKandangIDs,
); err != nil {
return nil, err
}
transferDate, err := time.Parse("2006-01-02", req.TransferDate) transferDate, err := time.Parse("2006-01-02", req.TransferDate)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transfer date format") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transfer date format")
} }
var totalSourceQty, totalTargetQty float64
sourceWarehouseMap := make(map[uint]uint)
for _, sourceDetail := range req.SourceKandangs {
if sourceDetail.Quantity <= 0 {
continue
}
totalSourceQty += sourceDetail.Quantity
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId)
if err != nil {
return nil, err
}
var totalPopulation float64
var productWarehouseId uint
for _, pop := range populations {
totalPopulation += pop.TotalQty
if productWarehouseId == 0 {
productWarehouseId = pop.ProductWarehouseId
}
}
if totalPopulation == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki populasi untuk ditransfer", sourceDetail.ProjectFlockKandangId))
}
if totalPopulation < sourceDetail.Quantity {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d jumlah tidak mencukupi. Tersedia: %.0f, Diminta: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity))
}
sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId
}
for _, targetDetail := range req.TargetKandangs {
if targetDetail.Quantity <= 0 {
continue
}
totalTargetQty += targetDetail.Quantity
}
if totalSourceQty == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang sumber dengan jumlah lebih dari 0")
}
if totalTargetQty == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang tujuan dengan jumlah lebih dari 0")
}
if totalSourceQty != totalTargetQty {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty))
}
// Ambil productWarehouseId pertama dari source yang valid (quantity > 0)
var firstProductWarehouseId uint
for _, sourceDetail := range req.SourceKandangs {
if sourceDetail.Quantity > 0 {
if pwId, ok := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]; ok {
firstProductWarehouseId = pwId
break
}
}
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
repoTx := s.Repository.WithTx(dbTransaction) repoTx := s.Repository.WithTx(dbTransaction)
sourceRepo := s.LayingTransferSourceRepo.WithTx(dbTransaction) sourceRepo := s.LayingTransferSourceRepo.WithTx(dbTransaction)
targetRepo := s.LayingTransferTargetRepo.WithTx(dbTransaction) targetRepo := s.LayingTransferTargetRepo.WithTx(dbTransaction)
pwRepo := rInventory.NewProductWarehouseRepository(dbTransaction)
// Hapus old sources dan targets // Hapus old sources dan targets
for _, oldSource := range existingTransfer.Sources { for _, oldSource := range existingTransfer.Sources {
@@ -460,26 +567,11 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
// Create new sources dengan pending quantity // Create new sources dengan pending quantity
for _, sourceDetail := range req.SourceKandangs { for _, sourceDetail := range req.SourceKandangs {
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) if sourceDetail.Quantity == 0 {
if err != nil { continue
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get populations")
} }
if len(populations) == 0 { productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no population available", sourceDetail.ProjectFlockKandangId))
}
var productWarehouseId uint
for _, pop := range populations {
if pop.ProductWarehouseId > 0 {
productWarehouseId = pop.ProductWarehouseId
break
}
}
if productWarehouseId == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no product warehouse", sourceDetail.ProjectFlockKandangId))
}
source := entity.LayingTransferSource{ source := entity.LayingTransferSource{
LayingTransferId: id, LayingTransferId: id,
@@ -494,7 +586,18 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
} }
} }
pwRepo := rInventory.NewProductWarehouseRepository(dbTransaction) // Ambil product ID dari source warehouse pertama yang valid
var sourceProductID uint
if firstProductWarehouseId > 0 {
sourcePW, err := pwRepo.GetByID(c.Context(), firstProductWarehouseId, nil)
if err == nil {
sourceProductID = sourcePW.ProductId
}
}
if sourceProductID == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product from source warehouse")
}
for _, targetDetail := range req.TargetKandangs { for _, targetDetail := range req.TargetKandangs {
targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId)
@@ -510,23 +613,6 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse")
} }
// Ambil product ID dari source yang pertama (semua sources seharusnya product-nya sama)
var sourceProductID uint
if len(req.SourceKandangs) > 0 {
firstSourceKandangID := req.SourceKandangs[0].ProjectFlockKandangId
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), firstSourceKandangID)
if err == nil && len(populations) > 0 && populations[0].ProductWarehouseId > 0 {
sourcePW, err := pwRepo.GetByID(c.Context(), populations[0].ProductWarehouseId, nil)
if err == nil {
sourceProductID = sourcePW.ProductId
}
}
}
if sourceProductID == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product from source warehouse")
}
targetPW, err := pwRepo.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) targetPW, err := pwRepo.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -571,6 +657,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
} }
func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil {
return err
}
_, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { _, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Preload("Sources.ProductWarehouse").Preload("Targets") return db.Preload("Sources.ProductWarehouse").Preload("Targets")
@@ -641,6 +730,12 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id")
} }
for _, approvableID := range approvableIDs {
if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), approvableID); err != nil {
return nil, err
}
}
step := utils.TransferToLayingStepPengajuan step := utils.TransferToLayingStepPengajuan
if action == entity.ApprovalActionApproved { if action == entity.ApprovalActionApproved {
step = utils.TransferToLayingStepDisetujui step = utils.TransferToLayingStepDisetujui
@@ -649,10 +744,10 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
repoTx := s.Repository.WithTx(dbTransaction) repoTx := s.Repository.WithTx(dbTransaction)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
stockAllocationRepo := commonRepo.NewStockAllocationRepository(dbTransaction)
// Gunakan repo baru untuk transaction scope agar bisa akses method custom
sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction)
targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction)
stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction)
for _, approvableID := range approvableIDs { for _, approvableID := range approvableIDs {
transfer, err := repoTx.GetByID(c.Context(), approvableID, nil) transfer, err := repoTx.GetByID(c.Context(), approvableID, nil)
@@ -687,23 +782,28 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil targets transfer") return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil targets transfer")
} }
// Hitung total quantity dari targets untuk di-consume dari sources
totalTargetQty := 0.0 totalTargetQty := 0.0
for _, target := range targets { for _, target := range targets {
totalTargetQty += target.TotalQty totalTargetQty += target.TotalQty
} }
// Consume dari laying_transfer_sources (Usable) - akan consume dari ProjectFlockPopulation (Stockable) totalSourceRequested := 0.0
for _, source := range sources {
totalSourceRequested += source.RequestedQty
}
for _, source := range sources { for _, source := range sources {
if source.ProductWarehouseId == nil { if source.ProductWarehouseId == nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", approvableID)) return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", approvableID))
} }
sourceShare := (source.RequestedQty / totalSourceRequested) * totalTargetQty
consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyTransferToLayingOut, UsableKey: fifo.UsableKeyTransferToLayingOut,
UsableID: source.Id, UsableID: source.Id,
ProductWarehouseID: *source.ProductWarehouseId, ProductWarehouseID: *source.ProductWarehouseId,
Quantity: totalTargetQty, Quantity: sourceShare,
AllowPending: false, AllowPending: false,
Tx: dbTransaction, Tx: dbTransaction,
}) })
@@ -717,6 +817,52 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
}, nil); err != nil { }, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty")
} }
targetShares := distributeProportionalWithRounding(targets, totalTargetQty, sourceShare)
for i, target := range targets {
roundedQty := math.Round(targetShares[i])
if roundedQty <= 0 {
continue
}
mappingAllocation := &entity.StockAllocation{
StockableType: fifo.UsableKeyTransferToLayingOut.String(),
StockableId: source.Id,
UsableType: fifo.StockableKeyTransferToLayingIn.String(),
UsableId: target.Id,
ProductWarehouseId: *source.ProductWarehouseId,
Qty: roundedQty,
Status: entity.StockAllocationStatusActive,
}
if err := stockAllocationRepo.CreateOne(c.Context(), mappingAllocation, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target")
}
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: *source.ProductWarehouseId,
CreatedBy: actorID,
Increase: 0,
Decrease: sourceShare,
LoggableType: string(utils.StockLogTypeTransferLaying),
LoggableId: approvableID,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *source.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
} }
for _, target := range targets { for _, target := range targets {
@@ -725,7 +871,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
} }
note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber)
replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyTransferToLayingIn, StockableKey: fifo.StockableKeyTransferToLayingIn,
StockableID: target.Id, StockableID: target.Id,
ProductWarehouseID: *target.ProductWarehouseId, ProductWarehouseID: *target.ProductWarehouseId,
@@ -738,10 +884,35 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
} }
if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{ if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{
"total_qty": replenishResult.AddedQuantity, "total_qty": target.TotalQty,
}, nil); err != nil { }, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty")
} }
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: *target.ProductWarehouseId,
CreatedBy: actorID,
Increase: target.TotalQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransferLaying),
LoggableId: approvableID,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *target.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
} }
} }
} }
@@ -788,36 +959,6 @@ func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayi
return err return err
} }
func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, tx *gorm.DB, productID uint, warehouseID uint, quantity float64, actorID uint, projectFlockKandangId *uint) (*entity.ProductWarehouse, error) {
productWarehouseRepoTx := rInventory.NewProductWarehouseRepository(tx)
existing, err := productWarehouseRepoTx.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID)
if err == nil && existing != nil {
if err := productWarehouseRepoTx.PatchOne(ctx, existing.Id, map[string]any{"qty": gorm.Expr("qty + ?", quantity)}, nil); err != nil {
return nil, err
}
return existing, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
newWarehouse := &entity.ProductWarehouse{
ProductId: productID,
WarehouseId: warehouseID,
ProjectFlockKandangId: projectFlockKandangId,
Quantity: quantity,
}
if err := productWarehouseRepoTx.CreateOne(ctx, newWarehouse, nil); err != nil {
return nil, err
}
return newWarehouse, nil
}
func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) { func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) {
pf, err := s.ProjectFlockRepo.GetByID(ctx.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { pf, err := s.ProjectFlockRepo.GetByID(ctx.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB {
@@ -911,3 +1052,34 @@ func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFl
return kandangMaxTargetQty, nil return kandangMaxTargetQty, nil
} }
func distributeProportionalWithRounding(targets []entity.LayingTransferTarget, totalTargetQty, sourceShare float64) []float64 {
if len(targets) == 0 {
return []float64{}
}
targetShares := make([]float64, len(targets))
totalRounded := 0.0
for i, target := range targets {
targetShares[i] = (target.TotalQty / totalTargetQty) * sourceShare
totalRounded += math.Round(targetShares[i])
}
diff := sourceShare - totalRounded
if diff != 0 {
maxIdx := 0
maxDecimal := 0.0
for i, share := range targetShares {
decimal := share - math.Round(share)
if decimal > maxDecimal {
maxDecimal = decimal
maxIdx = i
}
}
targetShares[maxIdx] += diff
}
return targetShares
}
@@ -30,7 +30,7 @@ type Update struct {
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,gt=0"`
Search string `query:"search" validate:"omitempty"` Search string `query:"search" validate:"omitempty"`
StartDate string `query:"start_date" validate:"omitempty"` StartDate string `query:"start_date" validate:"omitempty"`
EndDate string `query:"end_date" validate:"omitempty"` EndDate string `query:"end_date" validate:"omitempty"`
@@ -12,7 +12,7 @@ import (
type UniformityRepository interface { type UniformityRepository interface {
repository.BaseRepository[entity.ProjectFlockKandangUniformity] repository.BaseRepository[entity.ProjectFlockKandangUniformity]
GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query, modifiers ...func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandangUniformity, int64, error)
WithDefaultRelations() func(*gorm.DB) *gorm.DB WithDefaultRelations() func(*gorm.DB) *gorm.DB
DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error
} }
@@ -27,9 +27,15 @@ func NewUniformityRepository(db *gorm.DB) UniformityRepository {
} }
} }
func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query, modifiers ...func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandangUniformity, int64, error) {
return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB {
return r.applyQueryFilters(r.WithDefaultRelations()(db), params) db = r.applyQueryFilters(r.WithDefaultRelations()(db), params)
for _, modifier := range modifiers {
if modifier != nil {
db = modifier(db)
}
}
return db
}) })
} }
@@ -87,9 +87,20 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent
return nil, 0, err return nil, 0, err
} }
offset := (params.Page - 1) * params.Limit var scopeErr error
uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params)
offset := (params.Page - 1) * params.Limit
uniformitys, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params, func(db *gorm.DB) *gorm.DB {
db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = project_flock_kandang_uniformity.project_flock_kandang_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
db, scopeErr = m.ApplyLocationScope(c, db, "pf.location_id")
return db
})
if scopeErr != nil {
return nil, 0, scopeErr
}
if err != nil { if err != nil {
s.Log.Errorf("Failed to get uniformitys: %+v", err) s.Log.Errorf("Failed to get uniformitys: %+v", err)
return nil, 0, err return nil, 0, err
@@ -101,6 +112,10 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent
} }
func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) {
if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
uniformity, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) uniformity, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations())
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
@@ -326,6 +341,9 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), 0, req.ProjectFlockKandangId); err != nil {
return nil, err
}
if s.ProjectFlockKandangRepo == nil { if s.ProjectFlockKandangRepo == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available") return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available")
} }
@@ -484,6 +502,9 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
updateBody := make(map[string]any) updateBody := make(map[string]any)
var uniformDate *time.Time var uniformDate *time.Time
@@ -699,6 +720,10 @@ func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint,
} }
func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error { func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if err := s.Repository.DeleteOne(c.Context(), id); 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")
@@ -729,6 +754,11 @@ func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]en
if len(ids) == 0 { if len(ids) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id")
} }
for _, id := range ids {
if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
}
step := utils.UniformityStepPengajuan step := utils.UniformityStepPengajuan
if action == entity.ApprovalActionApproved { if action == entity.ApprovalActionApproved {
@@ -18,9 +18,9 @@ import (
rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -125,6 +125,11 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return nil, 0, err return nil, 0, err
} }
scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo)
@@ -148,6 +153,21 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = db.Where("created_at < ?", *createdTo) db = db.Where("created_at < ?", *createdTo)
} }
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
WHERE pi.purchase_id = purchases.id AND w.location_id IN ?
)`,
scope.IDs,
)
}
if params.AreaID > 0 { if params.AreaID > 0 {
db = db.Where( db = db.Where(
`EXISTS ( `EXISTS (
@@ -202,7 +222,42 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
} }
func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) {
return s.loadPurchase(c.Context(), id) scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
if err != nil {
return nil, err
}
purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
WHERE pi.purchase_id = purchases.id AND w.location_id IN ?
)`,
scope.IDs,
)
}
return db
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, utils.NotFound("Purchase not found")
}
s.Log.Errorf("Failed to get purchase %d: %+v", id, err)
return nil, utils.Internal("Failed to get purchase")
}
if err := s.attachLatestApproval(c.Context(), purchase); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err)
}
s.applyTravelDocumentURLs(c.Context(), purchase)
return purchase, nil
} }
func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) {
@@ -1026,10 +1081,25 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
LoggableId: purchase.Id, LoggableId: purchase.Id,
Notes: receiveNote, Notes: receiveNote,
} }
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, entry.pwID, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
} else {
log.Stock = 0
}
if entry.delta > 0 { if entry.delta > 0 {
log.Increase = entry.delta log.Increase = entry.delta
log.Stock += log.Increase
} else { } else {
log.Decrease = -entry.delta log.Decrease = -entry.delta
log.Stock -= log.Decrease
} }
logs = append(logs, log) logs = append(logs, log)
} }
@@ -1256,6 +1326,10 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
} }
ctx := c.Context() ctx := c.Context()
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return err
}
purchase, err := s.loadPurchase(ctx, id) purchase, err := s.loadPurchase(ctx, id)
if err != nil { if err != nil {
return err return err
@@ -1269,7 +1343,16 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
itemsToDelete[i] = item itemsToDelete[i] = item
} }
note := fmt.Sprintf("Purchase-Delete#%d", purchase.Id)
if purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" {
note = fmt.Sprintf("%s#delete", strings.TrimSpace(*purchase.PoNumber))
}
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil {
return err
}
approvalRepoTx := commonRepo.NewApprovalRepository(tx) approvalRepoTx := commonRepo.NewApprovalRepository(tx)
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowPurchase.String(), uint(id)); err != nil { if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowPurchase.String(), uint(id)); err != nil {
return err return err
@@ -1305,6 +1388,91 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
return nil return nil
} }
func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB, items []entity.PurchaseItem, note string, actorID uint) error {
if len(items) == 0 {
return nil
}
pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx)
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
deltas := make(map[uint]float64)
affected := make(map[uint]struct{})
logEntries := make([]struct {
pwID uint
qty float64
}, 0, len(items))
for _, item := range items {
if item.ProductWarehouseId == nil || *item.ProductWarehouseId == 0 {
continue
}
if item.TotalQty == 0 {
continue
}
pwID := *item.ProductWarehouseId
qty := item.TotalQty
if s.FifoSvc != nil {
if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyPurchaseItems,
StockableID: item.Id,
ProductWarehouseID: pwID,
Quantity: -qty,
Tx: tx,
}); err != nil {
return err
}
logEntries = append(logEntries, struct {
pwID uint
qty float64
}{pwID: pwID, qty: qty})
continue
}
deltas[pwID] -= qty
affected[pwID] = struct{}{}
logEntries = append(logEntries, struct {
pwID uint
qty float64
}{pwID: pwID, qty: qty})
}
if s.FifoSvc == nil && len(deltas) > 0 {
if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil {
return err
}
if len(affected) > 0 {
if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil {
return err
}
}
}
if strings.TrimSpace(note) != "" && actorID != 0 && len(logEntries) > 0 {
logs := make([]*entity.StockLog, 0, len(logEntries))
for _, entry := range logEntries {
if entry.pwID == 0 || entry.qty <= 0 {
continue
}
logs = append(logs, &entity.StockLog{
ProductWarehouseId: entry.pwID,
CreatedBy: actorID,
Decrease: entry.qty,
LoggableType: string(utils.StockLogTypePurchase),
LoggableId: items[0].PurchaseId,
Notes: note,
})
}
if len(logs) > 0 {
if err := stockLogRepoTx.CreateMany(ctx, logs, nil); err != nil {
return err
}
}
}
return nil
}
func (s *purchaseService) createPurchaseApproval( func (s *purchaseService) createPurchaseApproval(
ctx context.Context, ctx context.Context,
db *gorm.DB, db *gorm.DB,
@@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"strings" "strings"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
@@ -49,6 +50,21 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
RealizationDate: ctx.Query("realization_date", ""), RealizationDate: ctx.Query("realization_date", ""),
} }
locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB())
if err != nil {
return err
}
areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB())
if err != nil {
return err
}
if locationScope.Restrict {
query.AllowedLocationIDs = toInt64Slice(locationScope.IDs)
}
if areaScope.Restrict {
query.AllowedAreaIDs = toInt64Slice(areaScope.IDs)
}
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
@@ -92,6 +108,29 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error {
SortOrder: ctx.Query("sort_order", ""), SortOrder: ctx.Query("sort_order", ""),
} }
locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB())
if err != nil {
return err
}
areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB())
if err != nil {
return err
}
if locationScope.Restrict {
allowed := toInt64Slice(locationScope.IDs)
if len(allowed) == 0 {
allowed = []int64{-1}
}
query.AllowedLocationIDs = allowed
}
if areaScope.Restrict {
allowed := toInt64Slice(areaScope.IDs)
if len(allowed) == 0 {
allowed = []int64{-1}
}
query.AllowedAreaIDs = allowed
}
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
@@ -132,6 +171,14 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error {
FilterBy: ctx.Query("filter_by", ""), FilterBy: ctx.Query("filter_by", ""),
} }
areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB())
if err != nil {
return err
}
if areaScope.Restrict {
query.AllowedAreaIDs = toInt64Slice(areaScope.IDs)
}
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
@@ -184,6 +231,21 @@ func (c *RepportController) GetDebtSupplier(ctx *fiber.Ctx) error {
SortOrder: ctx.Query("sort_order", ""), SortOrder: ctx.Query("sort_order", ""),
} }
locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB())
if err != nil {
return err
}
areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB())
if err != nil {
return err
}
if locationScope.Restrict {
query.AllowedLocationIDs = toInt64Slice(locationScope.IDs)
}
if areaScope.Restrict {
query.AllowedAreaIDs = toInt64Slice(areaScope.IDs)
}
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
@@ -320,6 +382,10 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error {
ProjectFlockKandangID: uint(projectFlockKandangID), ProjectFlockKandangID: uint(projectFlockKandangID),
} }
if err := m.EnsureProjectFlockKandangAccess(ctx, c.RepportService.DB(), 0, query.ProjectFlockKandangID); err != nil {
return err
}
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
@@ -367,3 +433,14 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
return result, nil return result, nil
} }
func toInt64Slice(ids []uint) []int64 {
if len(ids) == 0 {
return nil
}
out := make([]int64, 0, len(ids))
for _, id := range ids {
out = append(out, int64(id))
}
return out
}
@@ -30,7 +30,7 @@ type CustomerPaymentTransaction struct {
type CustomerPaymentRepository interface { type CustomerPaymentRepository interface {
GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error) GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error)
GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error) GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error)
GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint) ([]uint, int64, error)
} }
type customerPaymentRepositoryImpl struct { type customerPaymentRepositoryImpl struct {
@@ -146,7 +146,7 @@ func (r *customerPaymentRepositoryImpl) GetInitialBalanceByCustomer(ctx context.
return result.Nominal, nil return result.Nominal, nil
} }
func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) { func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int, allowedCustomerIDs []uint) ([]uint, int64, error) {
subQuery := r.db.WithContext(ctx). subQuery := r.db.WithContext(ctx).
Table("(" + Table("(" +
"SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " + "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " +
@@ -161,26 +161,36 @@ func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx conte
"AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" + "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" +
") as customer_ids") ") as customer_ids")
if len(allowedCustomerIDs) > 0 {
subQuery = subQuery.Where("customer_id IN ?", allowedCustomerIDs)
}
var total int64 var total int64
if err := subQuery.Count(&total).Error; err != nil { if err := subQuery.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var customerIDs []uint var customerIDs []uint
err := r.db.WithContext(ctx). query := r.db.WithContext(ctx).
Table("("+ Table("(" +
"SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp "+ "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " +
"INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id "+ "INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " +
"INNER JOIN marketings m ON m.id = mp.marketing_id "+ "INNER JOIN marketings m ON m.id = mp.marketing_id " +
"INNER JOIN customers c ON c.id = m.customer_id "+ "INNER JOIN customers c ON c.id = m.customer_id " +
"WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL "+ "WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " +
"UNION "+ "UNION " +
"SELECT DISTINCT c.id as customer_id FROM payments p "+ "SELECT DISTINCT c.id as customer_id FROM payments p " +
"INNER JOIN customers c ON c.id = p.party_id "+ "INNER JOIN customers c ON c.id = p.party_id " +
"WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' "+ "WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " +
"AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL"+ "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" +
") as customer_ids"). ") as customer_ids").
Select("customer_id"). Select("customer_id")
if len(allowedCustomerIDs) > 0 {
query = query.Where("customer_id IN ?", allowedCustomerIDs)
}
err := query.
Order("customer_id ASC"). Order("customer_id ASC").
Limit(limit). Limit(limit).
Offset(offset). Offset(offset).
@@ -70,6 +70,7 @@ func (r *debtSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filt
Model(&entity.Supplier{}). Model(&entity.Supplier{}).
Joins("JOIN purchases ON purchases.supplier_id = suppliers.id"). Joins("JOIN purchases ON purchases.supplier_id = suppliers.id").
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id").
Joins("JOIN warehouses w ON w.id = purchase_items.warehouse_id").
Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)).
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
@@ -79,6 +80,22 @@ func (r *debtSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filt
db = db.Where("suppliers.id IN ?", filters.SupplierIDs) db = db.Where("suppliers.id IN ?", filters.SupplierIDs)
} }
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("w.area_id IN ?", filters.AllowedAreaIDs)
}
}
if filters.AllowedLocationIDs != nil {
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("w.location_id IN ?", filters.AllowedLocationIDs)
}
}
if filters.StartDate != "" { if filters.StartDate != "" {
if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil {
db = db.Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), dateFrom) db = db.Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), dateFrom)
@@ -226,12 +243,29 @@ func (r *debtSupplierRepositoryImpl) getPurchaseIDs(ctx context.Context, supplie
Table("purchases"). Table("purchases").
Select("DISTINCT purchases.id"). Select("DISTINCT purchases.id").
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id").
Joins("JOIN warehouses w ON w.id = purchase_items.warehouse_id").
Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)).
Where("purchases.supplier_id IN ?", supplierIDs). Where("purchases.supplier_id IN ?", supplierIDs).
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("purchase_items.received_date IS NOT NULL") Where("purchase_items.received_date IS NOT NULL")
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("w.area_id IN ?", filters.AllowedAreaIDs)
}
}
if filters.AllowedLocationIDs != nil {
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("w.location_id IN ?", filters.AllowedLocationIDs)
}
}
if filters.StartDate != "" { if filters.StartDate != "" {
if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil {
db = db.Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), dateFrom) db = db.Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), dateFrom)
@@ -74,10 +74,18 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context,
Where("products.product_category_id = ?", filters.ProductCategoryId) Where("products.product_category_id = ?", filters.ProductCategoryId)
} }
if filters.AreaId > 0 { if filters.AreaId > 0 || filters.AllowedAreaIDs != nil {
db = db. db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id")
Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). if filters.AreaId > 0 {
Where("warehouses.area_id = ?", filters.AreaId) db = db.Where("warehouses.area_id = ?", filters.AreaId)
}
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("warehouses.area_id IN ?", filters.AllowedAreaIDs)
}
}
} }
if filters.StartDate != "" { if filters.StartDate != "" {
@@ -189,10 +197,18 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context
Where("products.product_category_id = ?", filters.ProductCategoryId) Where("products.product_category_id = ?", filters.ProductCategoryId)
} }
if filters.AreaId > 0 { if filters.AreaId > 0 || filters.AllowedAreaIDs != nil {
db = db. db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id")
Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). if filters.AreaId > 0 {
Where("warehouses.area_id = ?", filters.AreaId) db = db.Where("warehouses.area_id = ?", filters.AreaId)
}
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("warehouses.area_id IN ?", filters.AllowedAreaIDs)
}
}
} }
if filters.StartDate != "" { if filters.StartDate != "" {
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
@@ -45,12 +46,13 @@ type RepportService interface {
GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error)
GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error)
GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error)
DB() *gorm.DB
} }
type repportService struct { type repportService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
DB *gorm.DB db *gorm.DB
ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository
MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository
PurchaseRepo purchaseRepo.PurchaseRepository PurchaseRepo purchaseRepo.PurchaseRepository
@@ -99,7 +101,7 @@ func NewRepportService(
return &repportService{ return &repportService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
DB: db, db: db,
ExpenseRealizationRepo: expenseRealizationRepo, ExpenseRealizationRepo: expenseRealizationRepo,
MarketingDeliveryRepo: marketingDeliveryRepo, MarketingDeliveryRepo: marketingDeliveryRepo,
PurchaseRepo: purchaseRepo, PurchaseRepo: purchaseRepo,
@@ -118,6 +120,10 @@ func NewRepportService(
} }
} }
func (s *repportService) DB() *gorm.DB {
return s.db
}
func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) { func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, err return nil, 0, err
@@ -405,11 +411,38 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C
return nil, 0, err return nil, 0, err
} }
locationScope, err := m.ResolveLocationScope(ctx, s.DB())
if err != nil {
return nil, 0, err
}
areaScope, err := m.ResolveAreaScope(ctx, s.DB())
if err != nil {
return nil, 0, err
}
restrictScope := locationScope.Restrict || areaScope.Restrict
var allowedCustomerIDs []uint
if restrictScope {
if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) {
return []dto.CustomerPaymentReportItem{}, 0, nil
}
allowedCustomerIDs, err = s.getCustomerIDsByScope(ctx.Context(), locationScope.IDs, areaScope.IDs)
if err != nil {
return nil, 0, err
}
if len(allowedCustomerIDs) == 0 {
return []dto.CustomerPaymentReportItem{}, 0, nil
}
}
var customerIDs []uint var customerIDs []uint
var totalCustomers int64 var totalCustomers int64
if len(params.CustomerIDs) > 0 { if len(params.CustomerIDs) > 0 {
customerIDs = params.CustomerIDs customerIDs = params.CustomerIDs
if restrictScope {
customerIDs = intersectUint(customerIDs, allowedCustomerIDs)
}
totalCustomers = int64(len(customerIDs)) totalCustomers = int64(len(customerIDs))
if len(customerIDs) == 0 { if len(customerIDs) == 0 {
@@ -428,7 +461,7 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C
offset := (page - 1) * limit offset := (page - 1) * limit
var err error var err error
customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset) customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset, allowedCustomerIDs)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -454,6 +487,37 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C
return result, totalCustomers, nil return result, totalCustomers, nil
} }
func (s *repportService) getCustomerIDsByScope(ctx context.Context, locationIDs, areaIDs []uint) ([]uint, error) {
if len(locationIDs) == 0 && len(areaIDs) == 0 {
return []uint{}, nil
}
db := s.db.WithContext(ctx).
Table("customers c").
Select("DISTINCT c.id").
Joins("JOIN marketings m ON m.customer_id = c.id").
Joins("JOIN marketing_products mp ON mp.marketing_id = m.id").
Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id").
Joins("JOIN product_warehouses pw ON pw.id = mdp.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("mdp.delivery_date IS NOT NULL").
Where("m.deleted_at IS NULL").
Where("c.deleted_at IS NULL")
if len(locationIDs) > 0 {
db = db.Where("w.location_id IN ?", locationIDs)
}
if len(areaIDs) > 0 {
db = db.Where("w.area_id IN ?", areaIDs)
}
var customerIDs []uint
if err := db.Pluck("c.id", &customerIDs).Error; err != nil {
return nil, err
}
return customerIDs, nil
}
func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint, params *validation.CustomerPaymentQuery) (dto.CustomerPaymentReportItem, error) { func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint, params *validation.CustomerPaymentQuery) (dto.CustomerPaymentReportItem, error) {
customer, err := s.CustomerRepo.GetByID(ctx, customerID, nil) customer, err := s.CustomerRepo.GetByID(ctx, customerID, nil)
@@ -801,7 +865,7 @@ func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKa
} }
var rows []entity.ProjectFlockKandangUniformity var rows []entity.ProjectFlockKandangUniformity
if err := s.DB.WithContext(ctx). if err := s.db.WithContext(ctx).
Model(&entity.ProjectFlockKandangUniformity{}). Model(&entity.ProjectFlockKandangUniformity{}).
Select("week, uniformity, uniform_date, id, chart_data"). Select("week, uniformity, uniform_date, id, chart_data").
Where("project_flock_kandang_id = ?", projectFlockKandangID). Where("project_flock_kandang_id = ?", projectFlockKandangID).
@@ -1886,6 +1950,36 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
} }
locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB())
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, err
}
areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB())
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, err
}
if locationScope.Restrict {
allowed := toInt64Slice(locationScope.IDs)
if len(allowed) == 0 {
locationIDs = []int64{-1}
} else if len(locationIDs) > 0 {
locationIDs = intersectInt64(locationIDs, allowed)
} else {
locationIDs = allowed
}
}
if areaScope.Restrict {
allowed := toInt64Slice(areaScope.IDs)
if len(allowed) == 0 {
areaIDs = []int64{-1}
} else if len(areaIDs) > 0 {
areaIDs = intersectInt64(areaIDs, allowed)
} else {
areaIDs = allowed
}
}
weightMin, err := parseOptionalFloat64(rawWeightMin) weightMin, err := parseOptionalFloat64(rawWeightMin)
if err != nil { if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
@@ -1952,6 +2046,51 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
return result, nil return result, nil
} }
func toInt64Slice(ids []uint) []int64 {
if len(ids) == 0 {
return nil
}
out := make([]int64, 0, len(ids))
for _, id := range ids {
out = append(out, int64(id))
}
return out
}
func intersectInt64(a, b []int64) []int64 {
if len(a) == 0 || len(b) == 0 {
return nil
}
set := make(map[int64]struct{}, len(b))
for _, id := range b {
set[id] = struct{}{}
}
out := make([]int64, 0, len(a))
for _, id := range a {
if _, ok := set[id]; ok {
out = append(out, id)
}
}
return out
}
func intersectUint(a, b []uint) []uint {
if len(a) == 0 || len(b) == 0 {
return nil
}
set := make(map[uint]struct{}, len(b))
for _, id := range b {
set[id] = struct{}{}
}
out := make([]uint, 0, len(a))
for _, id := range a {
if _, ok := set[id]; ok {
out = append(out, id)
}
}
return out
}
func parseOptionalFloat64(raw string) (*float64, error) { func parseOptionalFloat64(raw string) (*float64, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -1,59 +1,66 @@
package validation package validation
type ExpenseQuery struct { type ExpenseQuery 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"`
Search string `query:"search" validate:"omitempty,max=100"` Search string `query:"search" validate:"omitempty,max=100"`
Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"` Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"`
SupplierId int64 `query:"supplier_id" validate:"omitempty"` SupplierId int64 `query:"supplier_id" validate:"omitempty"`
KandangId int64 `query:"kandang_id" validate:"omitempty"` KandangId int64 `query:"kandang_id" validate:"omitempty"`
ProjectFlockKandangId int64 `query:"project_flock_kandang_id" validate:"omitempty"` ProjectFlockKandangId int64 `query:"project_flock_kandang_id" validate:"omitempty"`
ProjectFlockId int64 `query:"project_flock_id" validate:"omitempty"` ProjectFlockId int64 `query:"project_flock_id" validate:"omitempty"`
NonstockId int64 `query:"nonstock_id" validate:"omitempty"` NonstockId int64 `query:"nonstock_id" validate:"omitempty"`
AreaId int64 `query:"area_id" validate:"omitempty"` AreaId int64 `query:"area_id" validate:"omitempty"`
LocationId int64 `query:"location_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"`
RealizationDate string `query:"realization_date" validate:"omitempty"` RealizationDate string `query:"realization_date" validate:"omitempty"`
AllowedAreaIDs []int64 `query:"-"`
AllowedLocationIDs []int64 `query:"-"`
} }
type MarketingQuery struct { type MarketingQuery 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,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
Search string `query:"search" validate:"omitempty,max=100"` Search string `query:"search" validate:"omitempty,max=100"`
CustomerId int64 `query:"customer_id" validate:"omitempty"` CustomerId int64 `query:"customer_id" validate:"omitempty"`
ProductId int64 `query:"product_id" validate:"omitempty"` ProductId int64 `query:"product_id" validate:"omitempty"`
WarehouseId int64 `query:"warehouse_id" validate:"omitempty"` WarehouseId int64 `query:"warehouse_id" validate:"omitempty"`
SalesPersonId int64 `query:"sales_person_id" validate:"omitempty"` SalesPersonId int64 `query:"sales_person_id" validate:"omitempty"`
AreaId int64 `query:"area_id" validate:"omitempty"` AreaId int64 `query:"area_id" validate:"omitempty"`
LocationId int64 `query:"location_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"`
MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"` MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof= so_date realization_date"` FilterBy string `query:"filter_by" validate:"omitempty,oneof= so_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"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"` SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
AllowedAreaIDs []int64 `query:"-"`
AllowedLocationIDs []int64 `query:"-"`
} }
type PurchaseSupplierQuery struct { type PurchaseSupplierQuery 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,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
AreaId int64 `query:"area_id" validate:"omitempty"` AreaId int64 `query:"area_id" validate:"omitempty"`
SupplierId int64 `query:"supplier_id" validate:"omitempty"` SupplierId int64 `query:"supplier_id" validate:"omitempty"`
ProductId int64 `query:"product_id" validate:"omitempty"` ProductId int64 `query:"product_id" validate:"omitempty"`
ProductCategoryId int64 `query:"product_category_id" validate:"omitempty"` ProductCategoryId int64 `query:"product_category_id" validate:"omitempty"`
StartDate string `query:"start_date" validate:"omitempty"` StartDate string `query:"start_date" validate:"omitempty"`
EndDate string `query:"end_date" validate:"omitempty"` EndDate string `query:"end_date" validate:"omitempty"`
SortBy string `query:"sort_by" validate:"omitempty"` SortBy string `query:"sort_by" validate:"omitempty"`
FilterBy string `query:"filter_by" validate:"omitempty"` FilterBy string `query:"filter_by" validate:"omitempty"`
AllowedAreaIDs []int64 `query:"-"`
} }
type DebtSupplierQuery struct { type DebtSupplierQuery 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,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
SupplierIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` SupplierIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"`
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"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date"` FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
AllowedAreaIDs []int64 `query:"-"`
AllowedLocationIDs []int64 `query:"-"`
} }
type HppPerKandangQuery struct { type HppPerKandangQuery struct {
@@ -0,0 +1,331 @@
package controllers
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
type MasterDataController struct {
db *gorm.DB
redis *redis.Client
clients map[string]config.SSOClientConfig
drift time.Duration
nonceTTL time.Duration
localNonce sync.Map
}
type masterArea struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type masterLocation struct {
ID uint `json:"id"`
Name string `json:"name"`
AreaID uint `json:"area_id"`
}
func NewMasterDataController(db *gorm.DB, redis *redis.Client, clients map[string]config.SSOClientConfig) *MasterDataController {
normalized := make(map[string]config.SSOClientConfig, len(clients))
for alias, cfg := range clients {
alias = strings.ToLower(strings.TrimSpace(alias))
normalized[alias] = cfg
}
drift := config.SSOUserSyncDrift
if drift <= 0 {
drift = 2 * time.Minute
}
nonceTTL := config.SSOUserSyncNonceTTL
if nonceTTL <= 0 {
nonceTTL = 10 * time.Minute
}
return &MasterDataController{
db: db,
redis: redis,
clients: normalized,
drift: drift,
nonceTTL: nonceTTL,
}
}
func (h *MasterDataController) GetAreas(c *fiber.Ctx) error {
if _, _, err := h.authenticate(c, nil); err != nil {
return err
}
search := strings.TrimSpace(c.Query("search", ""))
ids := parseUintList(c.Query("ids", ""))
query := h.db.WithContext(c.Context()).
Model(&entity.Area{}).
Where("deleted_at IS NULL")
if search != "" {
query = query.Where("name ILIKE ?", "%"+search+"%")
}
if len(ids) > 0 {
query = query.Where("id IN ?", ids)
}
var areas []masterArea
if err := query.Order("name ASC").Find(&areas).Error; err != nil {
utils.Log.WithError(err).Error("failed to fetch areas for master data")
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch areas")
}
return c.Status(fiber.StatusOK).JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get areas successfully",
Data: areas,
})
}
func (h *MasterDataController) GetLocations(c *fiber.Ctx) error {
if _, _, err := h.authenticate(c, nil); err != nil {
return err
}
search := strings.TrimSpace(c.Query("search", ""))
areaIDs := parseUintList(c.Query("area_ids", ""))
ids := parseUintList(c.Query("ids", ""))
query := h.db.WithContext(c.Context()).
Model(&entity.Location{}).
Where("deleted_at IS NULL")
if search != "" {
query = query.Where("name ILIKE ?", "%"+search+"%")
}
if len(areaIDs) > 0 {
query = query.Where("area_id IN ?", areaIDs)
}
if len(ids) > 0 {
query = query.Where("id IN ?", ids)
}
var locations []masterLocation
if err := query.Order("name ASC").Find(&locations).Error; err != nil {
utils.Log.WithError(err).Error("failed to fetch locations for master data")
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch locations")
}
return c.Status(fiber.StatusOK).JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get locations successfully",
Data: locations,
})
}
func (h *MasterDataController) authenticate(c *fiber.Ctx, body []byte) (string, config.SSOClientConfig, error) {
rawAlias := strings.TrimSpace(c.Get("X-Sync-Client"))
if rawAlias == "" {
return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing sync client header")
}
aliasKey := strings.ToLower(rawAlias)
clientCfg, ok := h.clients[aliasKey]
if !ok {
return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "unknown sync client")
}
if err := h.verifyAuthorization(c, aliasKey); err != nil {
return "", config.SSOClientConfig{}, err
}
secret := strings.TrimSpace(clientCfg.SyncSecret)
if secret == "" {
return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "sync secret not configured")
}
timestamp := strings.TrimSpace(c.Get("X-Sync-Timestamp"))
nonce := strings.TrimSpace(c.Get("X-Sync-Nonce"))
signature := strings.TrimSpace(c.Get("X-Sync-Signature"))
if timestamp == "" || nonce == "" || signature == "" {
return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing signature headers")
}
if len(nonce) < 16 {
return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "nonce too short")
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusBadRequest, "invalid timestamp")
}
msgTime := time.Unix(ts, 0).UTC()
now := time.Now().UTC()
drift := now.Sub(msgTime)
if drift > h.drift || drift < -h.drift {
return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "timestamp outside allowed window")
}
providedSig, err := decodeMasterSignature(signature)
if err != nil {
return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature encoding")
}
expectedSignature := calculateSignature(secret, rawAlias, timestamp, nonce, body)
if !hmac.Equal(providedSig, expectedSignature) {
return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature")
}
if err := h.registerNonce(c.Context(), aliasKey, nonce); err != nil {
return "", config.SSOClientConfig{}, err
}
return aliasKey, clientCfg, nil
}
func (h *MasterDataController) verifyAuthorization(c *fiber.Ctx, alias string) error {
authHeader := strings.TrimSpace(c.Get(fiber.HeaderAuthorization))
if authHeader == "" {
return fiber.NewError(fiber.StatusUnauthorized, "missing authorization header")
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header")
}
token := strings.TrimSpace(parts[1])
if token == "" {
return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header")
}
verification, err := sso.VerifyAccessToken(token)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "invalid access token")
}
if verification.ServiceAlias == "" || verification.ServiceAlias != alias {
return fiber.NewError(fiber.StatusUnauthorized, "service subject mismatch")
}
if !hasAnyScope(verification.Claims.Scopes(), []string{"sync.master", "sync.users"}) {
return fiber.NewError(fiber.StatusForbidden, "missing sync scope")
}
return nil
}
func (h *MasterDataController) registerNonce(ctx context.Context, alias, nonce string) error {
ttl := h.nonceTTL
if ttl <= 0 {
ttl = 10 * time.Minute
}
key := fmt.Sprintf("sso:sync:%s:%s", alias, nonce)
if h.redis != nil {
stored, err := h.redis.SetNX(ctx, key, "1", ttl).Result()
if err == nil {
if !stored {
return fiber.NewError(fiber.StatusUnauthorized, "nonce already used")
}
return nil
}
utils.Log.WithError(err).Warn("store sync nonce failed")
}
now := time.Now().UTC()
if expRaw, ok := h.localNonce.Load(key); ok {
if expTime, ok := expRaw.(time.Time); ok && expTime.After(now) {
return fiber.NewError(fiber.StatusUnauthorized, "nonce already used")
}
}
h.localNonce.Store(key, now.Add(ttl))
return nil
}
func calculateSignature(secret, alias, timestamp, nonce string, body []byte) []byte {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(alias))
mac.Write([]byte("\n"))
mac.Write([]byte(timestamp))
mac.Write([]byte("\n"))
mac.Write([]byte(nonce))
mac.Write([]byte("\n"))
if len(body) > 0 {
mac.Write(body)
}
return mac.Sum(nil)
}
func decodeMasterSignature(sig string) ([]byte, error) {
sig = strings.TrimSpace(sig)
if sig == "" {
return nil, errors.New("empty signature")
}
if decoded, err := hex.DecodeString(sig); err == nil {
return decoded, nil
}
if decoded, err := base64.StdEncoding.DecodeString(sig); err == nil {
return decoded, nil
}
if decoded, err := base64.URLEncoding.DecodeString(sig); err == nil {
return decoded, nil
}
return nil, errors.New("unrecognized signature encoding")
}
func parseUintList(raw string) []uint {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]uint, 0, len(parts))
seen := make(map[uint]struct{}, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
val, err := strconv.ParseUint(part, 10, 64)
if err != nil || val == 0 {
continue
}
if _, ok := seen[uint(val)]; ok {
continue
}
seen[uint(val)] = struct{}{}
out = append(out, uint(val))
}
return out
}
func hasAnyScope(scopes []string, targets []string) bool {
if len(scopes) == 0 || len(targets) == 0 {
return false
}
for _, scope := range scopes {
scope = strings.ToLower(strings.TrimSpace(scope))
if scope == "" {
continue
}
for _, target := range targets {
if scope == strings.ToLower(strings.TrimSpace(target)) {
return true
}
}
}
return false
}
@@ -16,7 +16,7 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
"gitlab.com/mbugroup/lti-api.git/internal/sso" sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/secure" "gitlab.com/mbugroup/lti-api.git/internal/utils/secure"
) )
@@ -200,7 +200,7 @@ func (h *Controller) Refresh(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") return fiber.NewError(fiber.StatusUnauthorized, "invalid access token")
} }
issueCookies(c, struct { if err := issueCookies(c, struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
@@ -218,7 +218,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error {
IDToken: tokenResp.IDToken, IDToken: tokenResp.IDToken,
Error: tokenResp.Error, Error: tokenResp.Error,
Description: tokenResp.Description, Description: tokenResp.Description,
}, verification) }, verification); err != nil {
return err
}
utils.Log.WithFields(logrus.Fields{ utils.Log.WithFields(logrus.Fields{
"user_id": verification.UserID, "user_id": verification.UserID,
@@ -307,7 +309,9 @@ func (h *Controller) Callback(c *fiber.Ctx) error {
} }
// prepare cookies // prepare cookies
issueCookies(c, tokenResp, verification) if err := issueCookies(c, tokenResp, verification); err != nil {
return err
}
redirectTarget := sessionData.ReturnTo redirectTarget := sessionData.ReturnTo
if redirectTarget == "" { if redirectTarget == "" {
@@ -742,13 +746,21 @@ func issueCookies(c *fiber.Ctx, tokenResp struct {
IDToken string `json:"id_token"` IDToken string `json:"id_token"`
Error string `json:"error"` Error string `json:"error"`
Description string `json:"error_description"` Description string `json:"error_description"`
}, verification *sso.VerificationResult) { }, verification *sso.VerificationResult) error {
if revoker := session.GetRevocationStore(); revoker != nil && verification != nil { if revoker := session.GetRevocationStore(); revoker != nil && verification != nil {
if err := revoker.ClearUserLogout(c.Context(), verification.UserID); err != nil { if err := revoker.ClearUserLogout(c.Context(), verification.UserID); err != nil {
utils.Log.WithError(err).Warn("failed to clear logout marker") utils.Log.WithError(err).Warn("failed to clear logout marker")
} }
} }
if max := config.SSOAccessTokenMaxBytes; max > 0 && len(tokenResp.AccessToken) > max {
utils.Log.WithFields(logrus.Fields{
"token_len": len(tokenResp.AccessToken),
"max_len": max,
}).Warn("sso access token exceeds cookie size limit")
return fiber.NewError(fiber.StatusRequestEntityTooLarge, "access token too large")
}
accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access") accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access")
refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh")
maxAge := tokenResp.ExpiresIn maxAge := tokenResp.ExpiresIn
@@ -790,6 +802,7 @@ func issueCookies(c *fiber.Ctx, tokenResp struct {
// Optional: expose limited info via headers for FE debugging (avoid tokens) // Optional: expose limited info via headers for FE debugging (avoid tokens)
c.Set("X-Auth-User", fmt.Sprintf("%d", verification.UserID)) c.Set("X-Auth-User", fmt.Sprintf("%d", verification.UserID))
return nil
} }
func clearSSOCookie(c *fiber.Ctx, name string) { func clearSSOCookie(c *fiber.Ctx, name string) {
@@ -9,23 +9,24 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/config"
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/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
"gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
userRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" userRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/response" "gitlab.com/mbugroup/lti-api.git/internal/response"
"gitlab.com/mbugroup/lti-api.git/internal/sso"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
@@ -290,6 +291,8 @@ func (h *UserSyncController) upsertUser(c *fiber.Ctx, alias string, req *userSyn
"user_id": req.User.ID, "user_id": req.User.ID,
}).Info("sso user synced") }).Info("sso user synced")
sso.InvalidateProfileCache(c.Context(), uint(req.User.ID))
msg := fmt.Sprintf("User %s successfully", req.Action) msg := fmt.Sprintf("User %s successfully", req.Action)
return c.Status(fiber.StatusOK).JSON(response.Success{ return c.Status(fiber.StatusOK).JSON(response.Success{
Code: fiber.StatusOK, Code: fiber.StatusOK,
@@ -317,6 +320,8 @@ func (h *UserSyncController) logoutUser(c *fiber.Ctx, alias string, req *userSyn
"user_id": req.User.ID, "user_id": req.User.ID,
}).Info("sso user logout enforced") }).Info("sso user logout enforced")
sso.InvalidateProfileCache(c.Context(), uint(req.User.ID))
return c.Status(fiber.StatusOK).JSON(response.Common{ return c.Status(fiber.StatusOK).JSON(response.Common{
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
@@ -340,6 +345,8 @@ func (h *UserSyncController) removeUser(c *fiber.Ctx, alias string, req *userSyn
"user_id": req.User.ID, "user_id": req.User.ID,
}).Info("sso user deleted") }).Info("sso user deleted")
sso.InvalidateProfileCache(c.Context(), uint(req.User.ID))
return c.Status(fiber.StatusOK).JSON(response.Common{ return c.Status(fiber.StatusOK).JSON(response.Common{
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
+3
View File
@@ -26,6 +26,7 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
ctrl := ssoController.NewController(&http.Client{Timeout: 10 * time.Second}, store, session.GetRevocationStore()) ctrl := ssoController.NewController(&http.Client{Timeout: 10 * time.Second}, store, session.GetRevocationStore())
userRepo := userRepository.NewUserRepository(db) userRepo := userRepository.NewUserRepository(db)
syncCtrl := ssoController.NewUserSyncController(validate, userRepo, cache.Redis(), config.SSOClients) syncCtrl := ssoController.NewUserSyncController(validate, userRepo, cache.Redis(), config.SSOClients)
masterCtrl := ssoController.NewMasterDataController(db, cache.Redis(), config.SSOClients)
group := router.Group("/sso") group := router.Group("/sso")
group.Get("/start", middleware.NewLimiter(30, time.Minute), ctrl.Start) group.Get("/start", middleware.NewLimiter(30, time.Minute), ctrl.Start)
@@ -34,4 +35,6 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
group.Post("/refresh", middleware.NewLimiter(60, time.Minute), ctrl.Refresh) group.Post("/refresh", middleware.NewLimiter(60, time.Minute), ctrl.Refresh)
group.Post("/logout", middleware.NewLimiter(60, time.Minute), ctrl.Logout) group.Post("/logout", middleware.NewLimiter(60, time.Minute), ctrl.Logout)
group.Post("/users/sync", middleware.NewLimiter(30, time.Minute), syncCtrl.Sync) group.Post("/users/sync", middleware.NewLimiter(30, time.Minute), syncCtrl.Sync)
group.Get("/master/areas", middleware.NewLimiter(60, time.Minute), masterCtrl.GetAreas)
group.Get("/master/locations", middleware.NewLimiter(60, time.Minute), masterCtrl.GetLocations)
} }
@@ -39,6 +39,10 @@ type UserProfile struct {
UserID uint UserID uint
Roles []Role Roles []Role
Permissions []Permission Permissions []Permission
AreaIDs []uint
LocationIDs []uint
AllArea bool
AllLocation bool
} }
// Role describes a role assignment from the SSO profile response. // Role describes a role assignment from the SSO profile response.
@@ -145,6 +149,10 @@ func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error
} }
roles := envelope.getRoles() roles := envelope.getRoles()
areaIDs := envelope.getAreaIDs()
locationIDs := envelope.getLocationIDs()
allArea := envelope.getAllArea()
allLocation := envelope.getAllLocation()
profile := &UserProfile{} profile := &UserProfile{}
// Attempt to infer user id if provided. // Attempt to infer user id if provided.
@@ -183,6 +191,10 @@ func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error
} }
profile.Roles = convertedRoles profile.Roles = convertedRoles
profile.Permissions = perms profile.Permissions = perms
profile.AreaIDs = areaIDs
profile.LocationIDs = locationIDs
profile.AllArea = allArea
profile.AllLocation = allLocation
return profile, nil return profile, nil
} }
@@ -253,16 +265,44 @@ func profileCacheKey(userID uint) string {
return profileCachePrefix + strconv.FormatUint(uint64(userID), 10) return profileCachePrefix + strconv.FormatUint(uint64(userID), 10)
} }
// InvalidateProfileCache clears cached profile data for the given user in both local and Redis caches.
func InvalidateProfileCache(ctx context.Context, userID uint) {
if userID == 0 {
return
}
key := profileCacheKey(userID)
profileLocalCache.Delete(key)
client := cache.Redis()
if client == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
if err := client.Del(ctx, key).Err(); err != nil && !errors.Is(err, redis.Nil) {
utils.Log.WithError(err).Warn("sso profile redis delete failed")
}
}
func canonicalPermissionName(name string) string { func canonicalPermissionName(name string) string {
return strings.ToLower(strings.TrimSpace(name)) return strings.ToLower(strings.TrimSpace(name))
} }
// userInfoEnvelope handles the varying shapes returned by the SSO userinfo endpoint. // userInfoEnvelope handles the varying shapes returned by the SSO userinfo endpoint.
type userInfoEnvelope struct { type userInfoEnvelope struct {
Roles []userInfoRole `json:"roles"` Roles []userInfoRole `json:"roles"`
Data *struct { AreaIDs []uint `json:"area_ids"`
ID int64 `json:"id"` LocationIDs []uint `json:"location_ids"`
Roles []userInfoRole `json:"roles"` AllArea bool `json:"all_area"`
AllLocation bool `json:"all_location"`
Data *struct {
ID int64 `json:"id"`
Roles []userInfoRole `json:"roles"`
AreaIDs []uint `json:"area_ids"`
LocationIDs []uint `json:"location_ids"`
AllArea bool `json:"all_area"`
AllLocation bool `json:"all_location"`
} `json:"data"` } `json:"data"`
User *struct { User *struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -284,6 +324,46 @@ func (e *userInfoEnvelope) getRoles() []userInfoRole {
return nil return nil
} }
func (e *userInfoEnvelope) getAreaIDs() []uint {
if len(e.AreaIDs) > 0 {
return e.AreaIDs
}
if e.Data != nil && len(e.Data.AreaIDs) > 0 {
return e.Data.AreaIDs
}
return nil
}
func (e *userInfoEnvelope) getLocationIDs() []uint {
if len(e.LocationIDs) > 0 {
return e.LocationIDs
}
if e.Data != nil && len(e.Data.LocationIDs) > 0 {
return e.Data.LocationIDs
}
return nil
}
func (e *userInfoEnvelope) getAllArea() bool {
if e.AllArea {
return true
}
if e.Data != nil && e.Data.AllArea {
return true
}
return false
}
func (e *userInfoEnvelope) getAllLocation() bool {
if e.AllLocation {
return true
}
if e.Data != nil && e.Data.AllLocation {
return true
}
return false
}
type userInfoRole struct { type userInfoRole struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Key string `json:"key"` Key string `json:"key"`
+17 -6
View File
@@ -109,12 +109,23 @@ const (
type StockLogType string type StockLogType string
const ( const (
StockLogTypeAdjustment StockLogType = "ADJUSTMENT" StockLogTypeAdjustment StockLogType = "ADJUSTMENT"
StockLogTypeTransfer StockLogType = "TRANSFER" StockLogTypeTransfer StockLogType = "TRANSFER"
StockLogTypeMarketing StockLogType = "MARKETING" StockLogTypeTransferLaying StockLogType = "TRANSFER_LAYING"
StockLogTypeChikin StockLogType = "CHICKIN" StockLogTypeMarketing StockLogType = "MARKETING"
StockLogTypePurchase StockLogType = "PURCHASE" StockLogTypeChikin StockLogType = "CHICKIN"
StockLogTypeRecording StockLogType = "RECORDING" StockLogTypePurchase StockLogType = "PURCHASE"
StockLogTypeRecording StockLogType = "RECORDING"
)
// -------------------------------------------------------------------
// Transfer context
// -------------------------------------------------------------------
const (
TransferContextKey = "transfer_context"
TransferContextInventoryTransfer = "inventory_transfer"
TransferContextTransferToLaying = "transfer_to_laying"
) )
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -1,304 +0,0 @@
package test
import (
"context"
"math"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
)
// Test Transfer FIFO with Purchase as initial stockable
func TestTransferFIFO_PurchaseToTransfer(t *testing.T) {
db, fifoSvc := setupTransferFIFOTest(t)
ctx := context.Background()
// Setup warehouses
sourcePW := createProductWarehouseRow(t, db, 100) // 100 qty from purchase
destPW := createProductWarehouseRow(t, db, 0) // 0 qty initially
// Step 1: Simulate Purchase - Replenish stock to source warehouse
purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS")
if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: purchaseStockableKey,
StockableID: 1, // PurchaseItem ID
ProductWarehouseID: sourcePW.Id,
Quantity: 100,
}); err != nil {
t.Fatalf("Failed to replenish from purchase: %v", err)
}
// Verify source warehouse has stock
assertWarehouseQuantity(t, db, sourcePW.Id, 100)
assertAllocationCount(t, db, 1) // 1 allocation from purchase
// Step 2: Create Transfer - will consume from source (usable) and replenish to dest (stockable)
// Register Transfer as Usable (source warehouse - STOCK_TRANSFER_OUT)
transferUsableKey := fifo.UsableKey("STOCK_TRANSFER_OUT")
if err := fifoSvc.RegisterUsable(fifo.UsableConfig{
Key: transferUsableKey,
Table: "stock_transfer_details",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
t.Fatalf("Failed to register STOCK_TRANSFER_OUT as Usable: %v", err)
}
// Register Transfer as Stockable (destination warehouse - STOCK_TRANSFER_IN)
transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_IN")
if err := fifoSvc.RegisterStockable(fifo.StockableConfig{
Key: transferStockableKey,
Table: "stock_transfer_details",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "dest_product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
t.Fatalf("Failed to register STOCK_TRANSFER_IN as Stockable: %v", err)
}
// Create transfer detail record
transferDetail := entity.StockTransferDetail{
Id: 1,
StockTransferId: 1,
ProductId: 1,
SourceProductWarehouseID: uint64Ptr(uint64(sourcePW.Id)),
DestProductWarehouseID: uint64Ptr(uint64(destPW.Id)),
UsageQty: 0,
PendingQty: 0,
TotalQty: 0,
TotalUsed: 0,
}
transferDetailID := uint(transferDetail.Id)
if err := db.Create(&transferDetail).Error; err != nil {
t.Fatalf("Failed to create transfer detail: %v", err)
}
transferQty := 50.0
// Consume from source warehouse (STOCK_TRANSFER_OUT)
consumeResult, err := fifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: "STOCK_TRANSFER_OUT",
UsableID: transferDetailID,
ProductWarehouseID: sourcePW.Id,
Quantity: transferQty,
AllowPending: false, // Don't allow pending
})
if err != nil {
t.Fatalf("Failed to consume from source warehouse: %v", err)
}
// Verify consumption
if mathAbs(consumeResult.UsageQuantity-transferQty) > 1e-6 {
t.Fatalf("Expected usage quantity %.2f, got %.2f", transferQty, consumeResult.UsageQuantity)
}
if mathAbs(consumeResult.PendingQuantity) > 1e-6 {
t.Fatalf("Expected pending quantity 0, got %.2f", consumeResult.PendingQuantity)
}
// Update transfer detail usable fields
if err := db.Model(&entity.StockTransferDetail{}).
Where("id = ?", transferDetail.Id).
Updates(map[string]interface{}{
"usage_qty": consumeResult.UsageQuantity,
"pending_qty": consumeResult.PendingQuantity,
}).Error; err != nil {
t.Fatalf("Failed to update transfer detail usable fields: %v", err)
}
// Verify source warehouse decreased
assertWarehouseQuantity(t, db, sourcePW.Id, 50) // 100 - 50 = 50
// Verify allocation updated - should have 50 allocated to transfer
allocations := fetchAllocationsByUsable(t, db, "STOCK_TRANSFER_OUT", transferDetailID)
if len(allocations) != 1 {
t.Fatalf("Expected 1 allocation, got %d", len(allocations))
}
if mathAbs(allocations[0].Qty-transferQty) > 1e-6 {
t.Fatalf("Expected allocation qty %.2f, got %.2f", transferQty, allocations[0].Qty)
}
// Replenish to destination warehouse (STOCK_TRANSFER_IN)
note := "Transfer #1"
replenishResult, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: "STOCK_TRANSFER_IN",
StockableID: transferDetailID,
ProductWarehouseID: destPW.Id,
Quantity: transferQty,
Note: &note,
})
if err != nil {
t.Fatalf("Failed to replenish to destination warehouse: %v", err)
}
// Verify replenishment
if mathAbs(replenishResult.AddedQuantity-transferQty) > 1e-6 {
t.Fatalf("Expected added quantity %.2f, got %.2f", transferQty, replenishResult.AddedQuantity)
}
// Update transfer detail stockable fields
if err := db.Model(&entity.StockTransferDetail{}).
Where("id = ?", transferDetail.Id).
Updates(map[string]interface{}{
"total_qty": replenishResult.AddedQuantity,
}).Error; err != nil {
t.Fatalf("Failed to update transfer detail stockable fields: %v", err)
}
// Verify destination warehouse increased
assertWarehouseQuantity(t, db, destPW.Id, transferQty)
// Verify new stockable allocation created
stockableAllocations := fetchAllocationsByStockable(t, db, "STOCK_TRANSFER_IN", transferDetailID)
if len(stockableAllocations) != 1 {
t.Fatalf("Expected 1 stockable allocation, got %d", len(stockableAllocations))
}
if mathAbs(stockableAllocations[0].Qty-transferQty) > 1e-6 {
t.Fatalf("Expected stockable allocation qty %.2f, got %.2f", transferQty, stockableAllocations[0].Qty)
}
t.Logf("✅ Transfer FIFO test passed:")
t.Logf(" - Source warehouse: 100 → 50 (consumed %d)", int(transferQty))
t.Logf(" - Destination warehouse: 0 → %d (replenished)", int(transferQty))
t.Logf(" - Usable allocation: %.2f allocated to transfer", allocations[0].Qty)
t.Logf(" - Stockable allocation: %.2f available at destination", stockableAllocations[0].Qty)
}
// Setup function for transfer FIFO test
func setupTransferFIFOTest(t *testing.T) (*gorm.DB, commonSvc.FifoService) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(
&entity.ProductWarehouse{},
&entity.StockAllocation{},
&entity.StockTransferDetail{},
); err != nil {
t.Fatalf("auto migrate entities: %v", err)
}
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
// Register Purchase as Stockable
purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS")
if err := fifoSvc.RegisterStockable(fifo.StockableConfig{
Key: purchaseStockableKey,
Table: "purchase_items",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
t.Fatalf("register purchase stockable: %v", err)
}
return db, fifoSvc
}
// Helper functions
func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse {
t.Helper()
pw := entity.ProductWarehouse{
ProductId: 1,
WarehouseId: 1,
Quantity: qty,
}
if err := db.Create(&pw).Error; err != nil {
t.Fatalf("create product warehouse: %v", err)
}
return pw
}
func assertWarehouseQuantity(t *testing.T, db *gorm.DB, pwID uint, expected float64) {
t.Helper()
var pw entity.ProductWarehouse
if err := db.First(&pw, pwID).Error; err != nil {
t.Fatalf("fetch product warehouse %d: %v", pwID, err)
}
if mathAbs(pw.Quantity-expected) > 1e-6 {
t.Fatalf("expected warehouse quantity %.2f, got %.2f", expected, pw.Quantity)
}
}
func assertAllocationCount(t *testing.T, db *gorm.DB, expected int) {
t.Helper()
var count int64
if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil {
t.Fatalf("count allocations: %v", err)
}
if int(count) != expected {
t.Fatalf("expected %d allocations, got %d", expected, count)
}
}
func fetchAllocationsByUsable(t *testing.T, db *gorm.DB, usableType string, usableID uint) []entity.StockAllocation {
t.Helper()
var allocations []entity.StockAllocation
if err := db.Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Find(&allocations).Error; err != nil {
t.Fatalf("fetch allocations by usable: %v", err)
}
return allocations
}
func fetchAllocationsByStockable(t *testing.T, db *gorm.DB, stockableType string, stockableID uint) []entity.StockAllocation {
t.Helper()
var allocations []entity.StockAllocation
if err := db.Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
Find(&allocations).Error; err != nil {
t.Fatalf("fetch allocations by stockable: %v", err)
}
return allocations
}
func floatPtr(f float64) *float64 {
return &f
}
func uint64Ptr(u uint64) *uint64 {
return &u
}
func mathAbs(f float64) float64 {
return math.Abs(f)
}
func sanitizeKey(name string) string {
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return '_'
}, name)
}
@@ -1,446 +0,0 @@
package test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
servicePkg "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
)
func TestRecordingFIFO_CreatePendingWithoutStock(t *testing.T) {
db, svc, _, _ := setupRecordingFIFOTableTest(t)
ctx := context.Background()
recordingID := uint(1)
productWarehouse := createProductWarehouseRow(t, db, 0)
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks (pending) failed: %v", err)
}
updated := fetchRecordingStock(t, db, stock.Id)
assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should remain zero when no stock is available")
assertFloatEqual(t, 10, updated.PendingQty, "pending_qty should capture the entire request")
assertWarehouseQuantity(t, db, productWarehouse.Id, 0)
assertAllocationCount(t, db, 0)
assertAllocationCount(t, db, 0)
}
func TestRecordingFIFO_EditReallocatesUsage(t *testing.T) {
db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t)
ctx := context.Background()
recordingID := uint(1)
productWarehouse := createProductWarehouseRow(t, db, 0)
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
lot := createStockLot(t, db, productWarehouse.Id)
if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: stockableKey,
StockableID: lot.Id,
ProductWarehouseID: productWarehouse.Id,
Quantity: 12,
}); err != nil {
t.Fatalf("replenish failed: %v", err)
}
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks (initial) failed: %v", err)
}
assertWarehouseQuantity(t, db, productWarehouse.Id, 2)
desired := 4.0
stock.UsageQty = &desired
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks (edit) failed: %v", err)
}
updated := fetchRecordingStock(t, db, stock.Id)
assertFloatEqual(t, 4, updated.UsageQty, "usage_qty should reflect edited request")
assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should remain zero after downsize")
assertWarehouseQuantity(t, db, productWarehouse.Id, 8)
alloc := fetchSingleAllocation(t, db, stock.Id)
if alloc.Status != entity.StockAllocationStatusActive {
t.Fatalf("expected ACTIVE allocation, got %s", alloc.Status)
}
if mathAbs(alloc.Qty-4) > 1e-6 {
t.Fatalf("expected allocation qty 4, got %.3f", alloc.Qty)
}
}
func TestRecordingFIFO_DeleteReleasesStock(t *testing.T) {
db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t)
ctx := context.Background()
recordingID := uint(1)
productWarehouse := createProductWarehouseRow(t, db, 0)
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
lot := createStockLot(t, db, productWarehouse.Id)
if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: stockableKey,
StockableID: lot.Id,
ProductWarehouseID: productWarehouse.Id,
Quantity: 10,
}); err != nil {
t.Fatalf("replenish failed: %v", err)
}
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("consumeRecordingStocks failed: %v", err)
}
if err := svc.ReleaseRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
t.Fatalf("releaseRecordingStocks failed: %v", err)
}
updated := fetchRecordingStock(t, db, stock.Id)
assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should be cleared after delete")
assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should be cleared after delete")
assertWarehouseQuantity(t, db, productWarehouse.Id, 10)
alloc := fetchSingleAllocation(t, db, stock.Id)
if alloc.Status != entity.StockAllocationStatusReleased {
t.Fatalf("expected allocation to be released, got %s", alloc.Status)
}
}
// --- helpers ----------------------------------------------------------------
type recordingStockTable struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
UsageQty *float64 `gorm:"column:usage_qty"`
PendingQty *float64 `gorm:"column:pending_qty"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (recordingStockTable) TableName() string { return "recording_stocks" }
type productWarehouseTable struct {
Id uint `gorm:"primaryKey"`
ProductId uint `gorm:"column:product_id"`
WarehouseId uint `gorm:"column:warehouse_id"`
Quantity float64 `gorm:"column:quantity"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (productWarehouseTable) TableName() string { return "product_warehouses" }
type stockAllocationTable struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"not null"`
StockableType string `gorm:"size:100"`
StockableId uint
UsableType string `gorm:"size:100"`
UsableId uint
Qty float64 `gorm:"column:qty"`
Status string `gorm:"size:20"`
Note *string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
ReleasedAt *time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (stockAllocationTable) TableName() string { return "stock_allocations" }
type testStockSource struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
TotalQty float64 `gorm:"column:total_qty"`
TotalUsedQty float64 `gorm:"column:total_used_qty"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time
}
func (testStockSource) TableName() string { return "test_fifo_stockables" }
func setupRecordingFIFOTableTest(t *testing.T) (*gorm.DB, servicePkg.RecordingFIFOIntegrationService, commonSvc.FifoService, fifo.StockableKey) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&recordingStockTable{},
&productWarehouseTable{},
&stockAllocationTable{},
&testStockSource{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
if err := db.AutoMigrate(
&entity.ProductWarehouse{},
&entity.StockAllocation{},
&entity.RecordingStock{},
); err != nil {
t.Fatalf("auto migrate entities: %v", err)
}
stockAllocRepo := newFifoTestStockAllocationRepo(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
registerRecordingUsable(t, fifoSvc)
key := fifo.StockableKey(fmt.Sprintf("TEST_STOCKABLE_%s_%d", sanitizeKey(t.Name()), time.Now().UnixNano()))
if err := fifoSvc.RegisterStockable(fifo.StockableConfig{
Key: key,
Table: "test_fifo_stockables",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used_qty",
CreatedAt: "created_at",
},
}); err != nil {
t.Fatalf("register stockable: %v", err)
}
svc := servicePkg.NewRecordingFIFOIntegrationService(
recordingRepo.NewRecordingRepository(db),
productWarehouseRepo,
fifoSvc,
)
return db, svc, fifoSvc, key
}
func registerRecordingUsable(t *testing.T, fifoSvc commonSvc.FifoService) {
t.Helper()
err := fifoSvc.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingStock,
Table: "recording_stocks",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
})
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
t.Fatalf("register usable: %v", err)
}
if _, ok := fifo.Usable(fifo.UsableKeyRecordingStock); !ok {
t.Fatal("recording stock usable key not registered")
}
}
func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse {
t.Helper()
pw := entity.ProductWarehouse{
ProductId: 1,
WarehouseId: 1,
Quantity: qty,
// CreatedBy: 1,
}
if err := db.Create(&pw).Error; err != nil {
t.Fatalf("create product warehouse: %v", err)
}
return pw
}
func createRecordingStockRow(t *testing.T, db *gorm.DB, recordingID, productWarehouseID uint, desired float64) entity.RecordingStock {
t.Helper()
stock := entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: productWarehouseID,
UsageQty: floatPtr(0),
PendingQty: floatPtr(0),
}
if err := db.Create(&stock).Error; err != nil {
t.Fatalf("create recording stock: %v", err)
}
stock.UsageQty = floatPtr(desired)
return stock
}
func createStockLot(t *testing.T, db *gorm.DB, productWarehouseID uint) testStockSource {
t.Helper()
lot := testStockSource{
ProductWarehouseId: productWarehouseID,
CreatedAt: time.Now(),
}
if err := db.Create(&lot).Error; err != nil {
t.Fatalf("create stock lot: %v", err)
}
return lot
}
func fetchRecordingStock(t *testing.T, db *gorm.DB, id uint) entity.RecordingStock {
t.Helper()
var stock entity.RecordingStock
if err := db.First(&stock, id).Error; err != nil {
t.Fatalf("fetch recording stock: %v", err)
}
return stock
}
func fetchSingleAllocation(t *testing.T, db *gorm.DB, usableID uint) entity.StockAllocation {
t.Helper()
var alloc entity.StockAllocation
if err := db.Where("usable_id = ?", usableID).Order("created_at ASC").First(&alloc).Error; err != nil {
t.Fatalf("fetch allocation: %v", err)
}
return alloc
}
func assertAllocationCount(t *testing.T, db *gorm.DB, expected int64) {
t.Helper()
var count int64
if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil {
t.Fatalf("count allocations: %v", err)
}
if count != expected {
t.Fatalf("expected %d allocations, got %d", expected, count)
}
}
func assertWarehouseQuantity(t *testing.T, db *gorm.DB, id uint, expected float64) {
t.Helper()
var pw entity.ProductWarehouse
if err := db.First(&pw, id).Error; err != nil {
t.Fatalf("fetch product warehouse: %v", err)
}
if mathAbs(pw.Quantity-expected) > 1e-6 {
t.Fatalf("expected warehouse quantity %.3f, got %.3f", expected, pw.Quantity)
}
}
func assertFloatEqual(t *testing.T, expected float64, value *float64, msg string) {
t.Helper()
if value == nil {
t.Fatalf("expected %s %.3f, got nil", msg, expected)
}
if mathAbs(*value-expected) > 1e-6 {
t.Fatalf("%s: expected %.3f, got %.3f", msg, expected, *value)
}
}
func floatPtr(v float64) *float64 {
p := new(float64)
*p = v
return p
}
func mathAbs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func sanitizeKey(name string) string {
if name == "" {
return "CASE"
}
clean := strings.Map(func(r rune) rune {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
if r >= 'a' && r <= 'z' {
return r - 32
}
return '_'
}, name)
return clean
}
type fifoTestStockAllocationRepo struct {
commonRepo.StockAllocationRepository
db *gorm.DB
}
func newFifoTestStockAllocationRepo(db *gorm.DB) commonRepo.StockAllocationRepository {
return &fifoTestStockAllocationRepo{
StockAllocationRepository: commonRepo.NewStockAllocationRepository(db),
db: db,
}
}
func (r *fifoTestStockAllocationRepo) PatchOne(
ctx context.Context,
id uint,
updates map[string]any,
modifier func(*gorm.DB) *gorm.DB,
) error {
base := r.db
setClauses := make([]string, 0, len(updates))
args := make([]any, 0, len(updates)+1)
for column, value := range updates {
colName := column
if strings.EqualFold(column, "quantity") {
colName = "qty"
}
setClauses = append(setClauses, fmt.Sprintf("%s = ?", colName))
args = append(args, value)
}
args = append(args, id)
sql := fmt.Sprintf("UPDATE stock_allocations SET %s WHERE id = ?", strings.Join(setClauses, ", "))
result := base.Exec(sql, args...)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *fifoTestStockAllocationRepo) ReleaseByUsable(
ctx context.Context,
usableType string,
usableID uint,
note *string,
modifier func(*gorm.DB) *gorm.DB,
) error {
base := r.db
setClause := "status = ?, released_at = ?"
args := []any{entity.StockAllocationStatusReleased, time.Now()}
if note != nil {
setClause += ", note = ?"
args = append(args, *note)
}
args = append(args, usableType, usableID, entity.StockAllocationStatusActive)
sql := fmt.Sprintf(
"UPDATE stock_allocations SET %s WHERE usable_type = ? AND usable_id = ? AND status = ?",
setClause,
)
result := base.Exec(sql, args...)
return result.Error
}