Compare commits

...

327 Commits

Author SHA1 Message Date
ragilap 0ac174fdc6 [FEAT/BE] fix filter rejected delivery service 2026-02-22 21:12:57 +07:00
ragilap 4bf9b12680 [FEAT/BE] fix response closing and fix status rejected filter 2026-02-20 14:46:01 +07:00
ragilap 2028cee274 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/be/response-check-closing 2026-02-20 10:19:13 +07:00
ragilap 1c22c0f01c [FEAT/BE] fixing remaining stock check closing response 2026-02-20 10:19:00 +07:00
Adnan Zahir 5e28721651 Merge branch 'fix/stock' into 'development'
[FIX][BE]: fix insert stock to stocklog

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

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

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

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

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

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

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

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

See merge request mbugroup/lti-api!315
2026-02-07 16:59:19 +07:00
M1 AIR 1a56b37e4e Create job for MR 2026-02-06 23:36:20 +07:00
ragilap 4340828fec [FEAT/BE] Add telur seeder and saparator category 2026-02-06 19:26:00 +07:00
ragilap f74b6476de [FEAT/BE] Add filter delivery order, adjust response purchase and fcr growing recording 2026-02-06 14:13:05 +07:00
Hafizh A. Y. 3011735458 Merge branch 'fix/hpp-harian-feed' into 'development'
[FIX][BE]: add query for feed supplier

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

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

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

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

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

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

See merge request mbugroup/lti-api!306
2026-02-05 03:06:48 +00:00
aguhh18 aa1fd1c35b FEAT[BE] :update price calculation in sales order service for accurate rounding, add new conversion unit for quantity 2026-02-05 09:57:38 +07:00
giovanni 1f10e96288 fix query field source_warehouse 2026-02-04 15:17:05 +07:00
Hafizh A. Y. ae69b138bf Merge branch 'fix/hasil-produksi' into 'development'
[FIX][BE]: fix hasil produksi deplesi std dan filter recording approved

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

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

See merge request mbugroup/lti-api!303
2026-02-04 07:49:44 +00:00
aguhh18 1d95976360 FEAT[BE] :add marketing type field to delivery and sales order DTOs, enhance validation and service logic for consistent marketing type handling 2026-02-04 14:47:56 +07:00
giovanni 114f1a7c24 fix hasil produksi deplesi std dan filter recording approved 2026-02-04 13:51:55 +07:00
ragilap 14cc7ef2ae [FEAT/BE] Add field purchase response get all 2026-02-04 13:31:20 +07:00
aguhh18 357b5709f5 FEAT[BE] :add conversion fields and week tracking to marketing product DTOs and update mapping functions 2026-02-04 12:48:05 +07:00
aguhh18 474c42770b FEAT[BE] :add week calculation and chickin preload to product warehouse services 2026-02-04 11:46:32 +07:00
aguhh18 90de167fcd FEAT[BE] :add type filtering and validation to product warehouse services 2026-02-04 09:59:15 +07:00
giovanni 7183df6938 add query adjustment stock at closing sapronak 2026-02-04 09:17:16 +07:00
ragilap b862fc4113 [FEAT/BE]Fix remove fcr master data and changes to standart production 2026-02-03 17:01:50 +07:00
aguhh18 f59cdd821a FEAT[BE] :add marketing type and conversion fields to marketing entities and services 2026-02-03 13:32:37 +07:00
Hafizh A. Y. 22038533d7 Merge branch 'FEAT/BE/Production_standard' into 'development'
FEAT[BE] :add production standard detail creation for growing only FCR

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

See merge request mbugroup/lti-api!301
2026-02-03 05:09:53 +00:00
ragilap 4eacdd543a [FEAT/BE]Fix add new response depletions_rate 2026-02-03 12:08:11 +07:00
aguhh18 f75225b81b FEAT[BE] :add production standard detail creation for growing project category with zero target values 2026-02-03 12:02:45 +07:00
Hafizh A. Y. a0221bb79c Merge branch 'Fix/BE/recording_avaible_qty' into 'development'
[FEAT/BE]Fix create avaible qty

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

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

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

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

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

See merge request mbugroup/lti-api!294
2026-02-02 06:17:48 +00:00
aguhh18 5b80081d05 FIX[BE] :add handling for empty input in ToSummaryDto function to return zeroed summary 2026-02-02 13:02:30 +07:00
giovanni 9bf33d2bae add response stock to informasi stock product 2026-02-02 12:15:41 +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 3c77aff413 Merge branch 'dev/teguh' into 'development'
FEAT[BE]: transfer to laying, stock transfer, refactor adjustment, fix  age closing penjualan, filter transfer to laying

See merge request mbugroup/lti-api!256
2026-01-27 10:17:00 +07:00
Hafizh A. Y. f8d42dbdb3 Merge branch 'fix/BE/Report-purchasing-Debt-supplier-and-Closing-counting-sapronak' into 'development'
[FIX/BE-US] fix closing count sapronak,expense notes purchase

See merge request mbugroup/lti-api!255
2026-01-27 03:16:34 +00:00
Adnan Zahir e881c2b952 Merge branch 'FIX/BE/Closing_keuangan' into 'development'
[FIX]BE: fixing and refactoring closing keuangan to use hpp service and repo for some getter data

See merge request mbugroup/lti-api!254
2026-01-27 10:16:01 +07:00
Adnan Zahir 52ebcc5c2d Merge branch 'fix/hpp-calculate' into 'development'
[FIX][BE]: adjust calculate hpp filter delivery date marketing delivery

See merge request mbugroup/lti-api!253
2026-01-27 10:09:19 +07:00
aguhh18 3e0291c2ba Feat[BE]: enhance transfer laying functionality with comprehensive filtering options and improved DTO structures 2026-01-26 23:50:04 +07:00
aguhh18 7a704c4ec4 Feat[BE]: implement max target quantity retrieval for kandangs and update routes 2026-01-26 18:03:54 +07:00
ragilap bd0f89c521 [FIX/BE-US] fix closing count sapronak,expense notes purchase 2026-01-26 16:47:28 +07:00
aguhh18 b83ebc0ff9 Feat[BE] : add stock log to transfer service 2026-01-26 16:26:59 +07:00
aguhh18 1572dfd0b8 Refactor[BE] adjustment stock handling: remove stock_log_id, update relations, and enhance transfer logging 2026-01-26 16:26:20 +07:00
aguhh18 258fd1d7e0 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-01-26 13:40:17 +07:00
aguhh18 f44ddef79b Fix[BE]: update age calculation logic to include additional product flags and start day laying form week 18 2026-01-26 13:39:56 +07:00
aguhh18 9339e1e9f0 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into FIX/BE/Closing_keuangan 2026-01-26 13:01:16 +07:00
aguhh18 798dd7f9a3 Fix[BE]: fixing and refactoring closing keuangan to use hpp service and repo for some getter data 2026-01-26 13:00:30 +07:00
giovanni 7a8f813e1f adjust calculate hpp filter delivery date marketing delivery 2026-01-26 11:44:22 +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
Hafizh A. Y. fdd8e3ec31 Merge branch 'fix/hpp-calculate' into 'development'
[FIX][BE]: fix calculate sisa berat telur

See merge request mbugroup/lti-api!251
2026-01-24 09:34:24 +00:00
Hafizh A. Y. 0f6cd3a054 Merge branch 'fix/BE/UUIT-Recording-closing-report-uniformity-dashboard' into 'development'
Fix/be/uuit recording and dashboard

See merge request mbugroup/lti-api!250
2026-01-24 09:34:07 +00:00
Hafizh A. Y. c6d087eeab Merge branch 'fix/customer-finance' into 'development'
[FIX][BE] Edit customer, finance: bank optional, nominal minus, and filter

See merge request mbugroup/lti-api!249
2026-01-24 09:31:09 +00:00
Hafizh A. Y. 437cd3beda Merge branch 'fix/warehouse' into 'development'
[FIX][BE]: add filter location id

See merge request mbugroup/lti-api!248
2026-01-24 09:30:49 +00:00
ragilap 8fbce5a01e Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/UUIT-Recording-closing-report-uniformity-dashboard 2026-01-24 14:20:30 +07:00
ragilap f4b2408698 [FIX/BE-US] fix recording stock and dashboard filtering 2026-01-24 14:20:14 +07:00
giovanni 8c84981812 adjust get weight remaining 2026-01-24 14:17:50 +07:00
Hafizh A. Y 458c8e0a91 fix(BE): edit customer, finance: bank optional, nominal minus, and filter 2026-01-24 13:35:13 +07:00
giovanni 4646bf5577 add filter location id 2026-01-24 13:34:21 +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
giovanni 8b1831fc73 adjust take weight remaining 2026-01-24 12:03:09 +07:00
Adnan Zahir 919dc5c2e8 Merge branch 'dev/teguh' into 'development'
FIX[BE]: module transfer, report penjualan, closing penjualan

See merge request mbugroup/lti-api!244
2026-01-24 12:01:47 +07:00
Adnan Zahir 129d253683 Merge branch 'fix/hpp-calculate' into 'development'
[FIX][BE]: fix HPP get egg sales pieces and weight

See merge request mbugroup/lti-api!245
2026-01-24 11:38:31 +07:00
giovanni f69321d9cd fix get egg sales pieces and weight 2026-01-24 11:25:11 +07:00
aguhh18 74158138c0 Fix[BE]: enhance error logging and messages in transfer service 2026-01-24 10:38:52 +07:00
aguhh18 699a6e9289 Fix[BE]: update error message for insufficient stock in adjustment service 2026-01-24 09:40:00 +07:00
aguhh18 507d6c4293 FEAT[BE]: implement HPP on penjualan harian 2026-01-23 22:02:19 +07:00
aguhh18 43286cead1 Fix[BE]: fixing transfer to non kandang warehouse 2026-01-23 19:45:13 +07:00
aguhh18 1216e65419 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-01-23 17:54:58 +07:00
Adnan Zahir 42f030a780 Merge branch 'fix/data-production' into 'development'
[FIX][BE]: fix value all standard

See merge request mbugroup/lti-api!243
2026-01-23 16:27:54 +07:00
Adnan Zahir cf475d678e Merge branch 'fix/BE/Report-purchasing-Debt-supplier-and-Closing-counting-sapronak' into 'development'
[FIX/BE-US] fix closing counting sapronak

See merge request mbugroup/lti-api!242
2026-01-23 16:20:27 +07:00
Adnan Zahir a42c201ac6 Merge branch 'fix/hpp-calculate' into 'development'
[FIX][BE]: create common service calculate hpp

See merge request mbugroup/lti-api!241
2026-01-23 16:16:52 +07:00
Adnan Zahir d4699fba5b Merge branch 'fix/BE/UUIT-Recording-closing-report-uniformity-dashboard' into 'development'
Fix/be/uuit recording closing report uniformity dashboard

See merge request mbugroup/lti-api!240
2026-01-23 16:11:06 +07:00
giovanni e1ab5a90cb fix value all standard 2026-01-23 14:20:11 +07:00
aguhh18 f060da1cd3 FIX[BE]: Integrate StockLogRepository into deliveryOrdersService for stock logging functionality 2026-01-23 12:35:34 +07:00
ragilap f82ac01e7c [FIX/BE-US] fix recording date 2026-01-23 12:02:26 +07:00
ragilap fd2f773806 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/UUIT-Recording-closing-report-uniformity-dashboard 2026-01-23 11:57:38 +07:00
ragilap 7db3afe985 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/Report-purchasing-Debt-supplier-and-Closing-counting-sapronak 2026-01-23 11:53:54 +07:00
ragilap 8dc88b97a4 [FIX/BE-US] fix closing counting sapronak 2026-01-23 11:43:50 +07:00
aguhh18 f1787d3375 FIX[BE]: Fix wrong calculation avg sales on report penjualan harian 2026-01-23 11:07:11 +07:00
aguhh18 6b4eb758e4 FIX[BE]: fixing umur on closing penjualan for penjualan OVK and PAKAN 2026-01-23 10:32:40 +07:00
giovanni d54911f8b4 adjust value hpp 2026-01-23 10:29:48 +07:00
giovanni 1edd071a8a Merge branch 'development' into fix/hpp-calculate 2026-01-23 10:24:50 +07:00
giovanni fb565ef728 fix hpp harian kandang 2026-01-23 10:01:48 +07:00
ragilap 9928b4c970 [FIX/BE-US] fix uniformity relation chickin date 2026-01-22 17:50:04 +07:00
ragilap 8c58cc4103 [FIX/BE-US] fix uniformity relation chickin date 2026-01-22 17:49:46 +07:00
giovanni 0d585a99a6 adjust api hpp per kandang and implement common service hpp 2026-01-22 17:37:28 +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
M1 AIR b1b50c3c01 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into development 2026-01-22 15:57:47 +07:00
M1 AIR c085888ca9 Update cicd no migrate prod 2026-01-22 15:57:16 +07: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
Adnan Zahir 774c9b1d58 Merge branch 'fix/BE/US-281-adjustment-recording' into 'development'
[FIX/BE-US] fix day in recording

See merge request mbugroup/lti-api!237
2026-01-22 14:47:40 +07:00
ragilap 12ed9cd753 [FIX/BE-US] fix day in recording 2026-01-22 14:44:53 +07:00
Hafizh A. Y. 4ab1553340 Merge branch 'HOTFIX/BE/marketing' into 'development'
Hotfix/be/marketing : hotfix perhitungan penjualan ovk dan pakan, overhead only bop

See merge request mbugroup/lti-api!235
2026-01-22 07:43:10 +00:00
Hafizh A. Y. 3fa50b6344 Merge branch 'fix/BE/Report-purchasing-Debt-supplier-and-Closing-counting-sapronak' into 'development'
[FIX/BE-US] debt-supplier only show receive purchase

See merge request mbugroup/lti-api!232
2026-01-22 07:42:19 +00:00
Hafizh A. Y. 80424dee17 Merge branch 'fix/production-result' into 'development'
[FIX][BE]: remove max limit production result

See merge request mbugroup/lti-api!230
2026-01-22 07:42:02 +00:00
Hafizh A. Y. a9b33eaf28 Merge branch 'fix/BE/Purchase-edit-qty' into 'development'
[FIX/BE-US] adjustment recording and purchase stock log

See merge request mbugroup/lti-api!229
2026-01-22 07:41:50 +00:00
aguhh18 551534d02a Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into HOTFIX/BE/marketing 2026-01-22 14:32:39 +07:00
aguhh18 202a8ffc66 HOTFIX[BE]: filter closing overhead by expense category "BOP" 2026-01-22 14:29:56 +07:00
giovanni 6bc5e7d293 fix get average bw 2026-01-22 14:19:16 +07:00
ragilap 2e0827dec5 [FIX/BE-US] debt-supplier only show receive and changes lunas 2026-01-22 14:15:26 +07:00
aguhh18 87973a6c9f HOTFIX[BE]: update total price calculation based on product flags for delivery and sales orders 2026-01-22 14:15:03 +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
M1 AIR 1ca6c6a104 Fixing staging cicd 2026-01-22 14:12:20 +07: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
M1 AIR 78a45b11e7 fixing pipeline 2026-01-22 13:59:52 +07:00
giovanni 58b29501c0 finishing common service calculate hpp 2026-01-22 13:54:32 +07:00
ragilap bac0361df5 [FIX/BE-US] debt-supplier only show receive purchase 2026-01-22 13:53:43 +07: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
giovanni 645a97b460 remove max limit production result 2026-01-22 13:42:36 +07:00
ragilap eb2479cc41 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/Purchase-edit-qty 2026-01-22 13:36:53 +07:00
ragilap 04ec8560a7 [FIX/BE-US] adjustment recording and purchase stock log 2026-01-22 13:35:46 +07:00
Hafizh A. Y. 12240a9e2d Merge branch 'fix/mr-development-staging' into 'development'
Fix/mr development staging

See merge request mbugroup/lti-api!228
2026-01-22 06:17:31 +00:00
giovanni f2a46843c8 continue common service hpp 2026-01-22 12:53:02 +07:00
aguhh18 06e92d1c77 [FEAT][BE}: add umur week and day on closing penjualan 2026-01-22 11:17:02 +07:00
giovanni d7ed768d14 add filter project status and location id 2026-01-22 11:06:17 +07:00
ragilap f04cbd24bd [FIX/BE-US] adjustment recording 2026-01-22 11:06:17 +07:00
aguhh18 ec4b849778 FIX[BE]: fixing wrong index data on adjustment. change get from stocklogs to adjustment table 2026-01-22 11:06:17 +07:00
aguhh18 132e043597 FEAT[BE]: update warehouse DTO references in product warehouse and add UOM preload 2026-01-22 11:06:17 +07:00
aguhh18 e99af36796 FIX[BE] fix wrong calculation on summary report marketing 2026-01-22 11:06:17 +07:00
aguhh18 f6bdb17699 FIX[BE]: fixing report penjualan add avg weight and price to response 2026-01-22 11:06:17 +07:00
aguhh18 8f7fc622f6 FIX[BE]: fixing closing penjualan add sumary 2026-01-22 11:06:17 +07:00
aguhh18 30f5ed417c FEAT[BE]: add default filterby become so_date in report markeing 2026-01-22 11:06:17 +07:00
aguhh18 1d726afa6f FEAT[BE[: enhance marketing report items with aging days calculation 2026-01-22 11:06:17 +07:00
aguhh18 96ba947952 FEAT[BE[: add avg weight and avg amount on get penjualan harian 2026-01-22 11:06:17 +07:00
aguhh18 ad0504f49e refactor: unify GetOne method to return approval alongside transfer laying 2026-01-22 11:06:17 +07:00
giovanni 0b708cd57b fix data produksi not show response 2026-01-22 11:06:17 +07:00
ragilap 32153f02b8 [FIX/BE-US] purchase edit qty approval staf add adjustment fifo system 2026-01-22 11:06:17 +07:00
M1 AIR cf37822a07 Change rules cicd no conflicts 2026-01-22 11:06:16 +07:00
Hafizh A. Y. 7fb6c3c7bf Merge branch 'fix/sapronak' into 'development'
[FIX][BE]: add filter project status and location id

See merge request mbugroup/lti-api!226
2026-01-22 03:09:32 +00:00
giovanni fd689919b0 Merge branch 'development' into fix/hpp-calculate 2026-01-22 10:05:08 +07:00
giovanni 2ad0c17fbe add filter project status and location id 2026-01-22 10:00:24 +07:00
ragilap e8c7b3f2a8 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/Purchase-edit-qty 2026-01-22 09:50:21 +07:00
Hafizh A. Y. ce09c2473c Merge branch 'Fix/BE/US-281-adjustment-recording' into 'development'
[FIX/BE-US] adjustment recording

See merge request mbugroup/lti-api!222
2026-01-22 02:49:38 +00:00
Hafizh A. Y. 3493d1d7b2 Merge branch 'dev/teguh' into 'development'
[FIX][BE]: fixing bug after SIT module report marketing and closing marketing

See merge request mbugroup/lti-api!220
2026-01-22 02:48:41 +00:00
Hafizh A. Y. 9fff954857 Merge branch 'fix/LSS416' into 'development'
[FIX][BE]: fix data produksi not show response

See merge request mbugroup/lti-api!219
2026-01-22 02:48:13 +00:00
Hafizh A. Y. c7c1e4b335 Merge branch 'fix/BE/Purchase-edit-qty' into 'development'
[FIX/BE-US] purchase edit qty approval staf & add adjustment fifo system

See merge request mbugroup/lti-api!218
2026-01-22 02:47:35 +00:00
ragilap 5dbe9bb989 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/Purchase-edit-qty 2026-01-22 09:33:15 +07:00
aguhh18 e555cfa950 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-01-22 09:27:17 +07:00
ragilap 3a457ceee5 [FIX/BE-US] adjustment sso-location 2026-01-22 09:27:11 +07:00
M1 AIR 32a8557a3b Change rules cicd no conflicts 2026-01-21 15:56:58 +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
M1 AIR 1818f5a295 merge: sync staging with production 2026-01-21 15:16:36 +07:00
aguhh18 a73b44808f FIX[BE]: fixing wrong index data on adjustment. change get from stocklogs to adjustment table 2026-01-21 15:16:30 +07:00
Adnan Zahir 69d9137e78 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!224
2026-01-21 14:55:09 +07:00
Adnan Zahir e32b231c6c Merge branch 'fix/daily-checklist' into 'development'
fix

See merge request mbugroup/lti-api!223
2026-01-21 14:25:26 +07:00
giovanni ca6d0b160b fix 2026-01-21 14:17:43 +07:00
aguhh18 e8a89f0f17 FEAT[BE]: update warehouse DTO references in product warehouse and add UOM preload 2026-01-21 13:52:46 +07:00
giovanni d96a12776a next commit 2026-01-21 13:38:59 +07:00
Adnan Zahir adabb4e035 Merge branch 'fix/daily-checklist' into 'development'
[FIX][BE]: fix duplicate name, time type and phase id create phase activity

See merge request mbugroup/lti-api!221
2026-01-21 13:30:21 +07:00
ragilap 16a0b848bc [FIX/BE-US] adjustment recording 2026-01-21 13:06:45 +07:00
aguhh18 c2d2701d72 FIX[BE] fix wrong calculation on summary report marketing 2026-01-21 09:57:44 +07:00
aguhh18 894efa7aa5 FIX[BE]: fixing report penjualan add avg weight and price to response 2026-01-21 09:46:03 +07:00
aguhh18 d0625e7d21 FIX[BE]: fixing closing penjualan add sumary 2026-01-21 09:45:19 +07:00
giovanni 67ecdbc1dd fix duplicate name, time type and phase id create phase activity 2026-01-20 23:01:58 +07:00
aguhh18 d50ab7cc97 FEAT[BE]: add default filterby become so_date in report markeing 2026-01-20 22:42:16 +07:00
aguhh18 ad3bb0e29a FEAT[BE[: enhance marketing report items with aging days calculation 2026-01-20 22:28:34 +07:00
aguhh18 dd4dcc1c39 FEAT[BE[: add avg weight and avg amount on get penjualan harian 2026-01-20 22:10:47 +07:00
aguhh18 aa4da68680 refactor: unify GetOne method to return approval alongside transfer laying 2026-01-20 22:07:07 +07:00
giovanni 95965cb26a add common service and repo for calculate hpp 2026-01-20 18:21:49 +07:00
giovanni e4e17f16f9 fix data produksi not show response 2026-01-20 18:18:41 +07:00
ragilap edd77c5265 [FIX/BE-US] purchase edit qty approval staf add adjustment fifo system 2026-01-20 16:40:37 +07:00
Adnan Zahir dab692a0c1 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!217
2026-01-20 14:25:14 +07:00
Hafizh A. Y. e6244dea8a Merge branch 'Fix/BE/Purchase-edit-qty' into 'development'
[FIX/BE-US] purchase edit qty approval staf

See merge request mbugroup/lti-api!216
2026-01-20 07:21:27 +00:00
Hafizh A. Y. d1e4bf060e Merge branch 'fix/LSS416' into 'development'
[FIX][BE]: fix endpoint not found

See merge request mbugroup/lti-api!213
2026-01-20 07:21:16 +00:00
ragilap fc06b3e4db [FIX/BE-US] purchase edit qty approval staf 2026-01-20 14:19:02 +07:00
Adnan Zahir cf42e8c130 Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!215
2026-01-20 12:11:41 +07:00
Adnan Zahir 8ff97cb647 Merge branch 'revert-21de17b1' into 'development'
Revert "Merge branch 'staging' into 'development'"

See merge request mbugroup/lti-api!214
2026-01-20 12:08:53 +07:00
Adnan Zahir 1b7ce3c62c Revert "Merge branch 'staging' into 'development'"
This reverts merge request !212
2026-01-20 12:08:41 +07:00
Adnan Zahir 21de17b18a Merge branch 'staging' into 'development'
Staging

See merge request mbugroup/lti-api!212
2026-01-20 12:07:52 +07:00
giovanni 2aaaab91f7 fix endpoint not found 2026-01-20 12:07:16 +07:00
Adnan Zahir 4227152979 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!209
2026-01-20 11:56:00 +07:00
Hafizh A. Y. ec020ac17c Merge branch 'Fix/BE/UUIT-Recording-closing-report-uniformity-dashboard' into 'development'
[FIX/BE-US] recording,reporting,closing and uniformity

See merge request mbugroup/lti-api!211
2026-01-20 03:55:35 +00:00
Hafizh A. Y. adc30ad5cd Merge branch 'fix/LSS416' into 'development'
[FIX][BE]: adjust closing tap sapronak; add api summart total kuantitas per category and uom

See merge request mbugroup/lti-api!210
2026-01-20 03:54:47 +00:00
ragilap 9fb5395469 [FIX/BE-US] recording,reporting,closing and uniformity 2026-01-20 10:13:58 +07:00
giovanni bc771660be adjust closing tap sapronak; add api summart total kuantitas per category and uom 2026-01-20 10:03:57 +07:00
Adnan Zahir f2ae2cc731 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!204
2026-01-17 09:04:30 +07:00
Hafizh A. Y. 409652c15e Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!201
2026-01-15 11:55:47 +00:00
Adnan Zahir 9d611b7492 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!198
2026-01-15 18:21:47 +07:00
Adnan Zahir f13e4f907c Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!196
2026-01-15 17:59:24 +07:00
Adnan Zahir a5af469865 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!192
2026-01-15 15:58:37 +07:00
ragilap a3e9017e29 [FIX/BE-US] changes role to user and query 2026-01-15 14:43:59 +07:00
kris 3f4d6c630a Update .gitlab-ci.yml file 2026-01-15 06:46:07 +00: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
kris cad15bcd78 Update .gitlab-ci.yml file 2026-01-14 09:46:39 +00:00
kris e0ff6e6d79 Update .gitlab-ci.yml file 2026-01-14 08:26:07 +00:00
kris 9f1c153841 Merge branch 'development' into 'staging'
Edit .gitignore (Tom haye)

See merge request mbugroup/lti-api!184
2026-01-14 07:37:47 +00:00
kris dbf72c7248 Delete .air.toml 2026-01-14 07:06:37 +00:00
kris 894fa0b22a Update .gitlab-ci.yml file 2026-01-14 06:55:28 +00:00
Adnan Zahir d680196919 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!180
2026-01-14 13:48:25 +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
kris cfbe431222 Update .gitlab-ci.yml file 2026-01-13 04:43:44 +00:00
kris 4c434899aa Update .gitlab-ci.yml file 2026-01-13 04:36:34 +00:00
kris 7fd90f3268 Update .gitlab-ci.yml file 2026-01-13 04:19:00 +00:00
kris d26c2dba3f Update .gitlab-ci.yml file 2026-01-13 04:15:08 +00:00
M1 AIR f8415ea15d Update gitlab 2026-01-13 10:59:51 +07:00
M1 AIR 64fe845128 Update CICD 2026-01-13 10:46:55 +07:00
174 changed files with 12609 additions and 4600 deletions
+29 -84
View File
@@ -1,90 +1,35 @@
stages: workflow:
- deploy rules:
# MR pipeline
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: always
deploy-dev: # Push pipeline hanya untuk env branch
stage: deploy - if: '$CI_COMMIT_BRANCH == "development"'
image: alpine:3.20 when: always
variables: - if: '$CI_COMMIT_BRANCH == "staging"'
DEPLOY_APP: "LTI-MBUGROUP" when: always
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga - if: '$CI_COMMIT_BRANCH == "production"'
GIT_SUBMODULE_STRATEGY: recursive when: always
GIT_DEPTH: "1"
before_script: # Selain itu jangan buat pipeline
- echo "🧰 Installing dependencies..." - when: never
- apk update && apk add --no-cache openssh git curl bash
# Setup SSH di runner include:
- mkdir -p ~/.ssh # khusus MR (notif)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - local: "ci/merge_request.yml"
- chmod 600 ~/.ssh/id_rsa rules:
- eval "$(ssh-agent -s)" - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- ssh-add ~/.ssh/id_rsa
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif # khusus push ke branch env
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts - local: "ci/development.yml"
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts rules:
- if: '$CI_COMMIT_BRANCH == "development"'
script: - local: "ci/staging.yml"
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
- > - local: "ci/production.yml"
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " rules:
set -e - if: '$CI_COMMIT_BRANCH == "production"'
cd /home/devops/docker/deployment/development/lti-api
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
# Fetch/reset pakai SSH
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
git reset --hard origin/development
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
"; then
STATUS='success';
else
STATUS='failed';
fi;
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
if [ "$STATUS" = "success" ]; then
COLOR=3066993;
TITLE="✅ Deployment API Succeeded";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully.";
else
COLOR=15158332;
TITLE="❌ Deployment API Failed Gaes";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed.";
fi;
echo "{
\"username\": \"CI Bot\",
\"embeds\": [{
\"title\": \"$TITLE\",
\"description\": \"$DESC\",
\"color\": $COLOR,
\"fields\": [
{\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true},
{\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true},
{\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false},
{\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false}
]
}]
}" > payload.json;
echo "📡 Sending notification to Discord...";
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
only:
- development
environment:
name: development
+91
View File
@@ -0,0 +1,91 @@
stages:
- deploy
deploy-dev:
stage: deploy
image: alpine:3.20
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
when: on_success
- when: never
variables:
DEPLOY_APP: "LTI-MBUGROUP"
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
before_script:
- echo "🧰 Installing dependencies..."
- apk update && apk add --no-cache openssh git curl bash
# Setup SSH di runner
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
cd /home/devops/docker/deployment/development/lti-api
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
# Fetch/reset pakai SSH
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
git reset --hard origin/development
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
"; then
STATUS='success';
else
STATUS='failed';
fi;
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
if [ "$STATUS" = "success" ]; then
COLOR=3066993;
TITLE="✅ Deployment API Succeeded";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully.";
else
COLOR=15158332;
TITLE="❌ Deployment API Failed Gaes";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed.";
fi;
echo "{
\"username\": \"CI Bot\",
\"embeds\": [{
\"title\": \"$TITLE\",
\"description\": \"$DESC\",
\"color\": $COLOR,
\"fields\": [
{\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true},
{\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true},
{\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false},
{\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false}
]
}]
}" > payload.json;
echo "📡 Sending notification to Discord...";
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
environment:
name: development
+48
View File
@@ -0,0 +1,48 @@
stages:
- notify
notify_discord_on_mr_request_main_dev:
stage: notify
image: alpine:3.20
rules:
# hanya MR yang target ke main atau development
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")'
when: on_success
- when: never
script:
- apk add --no-cache curl jq coreutils
- |
TIME_HUMAN="$(date '+%d/%m/%y, %H.%M')"
TIME_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
TITLE="${CI_MERGE_REQUEST_TITLE}"
IID="!${CI_MERGE_REQUEST_IID}"
USER_LINE="${GITLAB_USER_NAME} (${GITLAB_USER_LOGIN})"
PROJECT_PATH="${CI_PROJECT_PATH}"
USERNAME="${GITLAB_USER_LOGIN}"
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
DESC="$(printf "**%s**\n\n%s opened merge request %s %s\n%s" \
"$USERNAME" "$USER_LINE" "$IID" "$TITLE" "$TIME_HUMAN")"
payload=$(jq -n \
--arg desc "$DESC" \
--arg project "$PROJECT_PATH" \
--arg timeiso "$TIME_ISO" \
--arg mrurl "$MR_URL" \
'{
"username": "Mock-api - Merge Requests",
"embeds": [
{
"description": ($desc + "\n" + $mrurl),
"color": 15105570,
"footer": { "text": $project },
"timestamp": $timeiso
}
]
}')
curl -sS -H "Content-Type: application/json" \
-d "$payload" \
"$DISCORD_WEBHOOK_URL"
+155
View File
@@ -0,0 +1,155 @@
stages:
- build
- migrate
- deploy
- seed
default:
tags:
- self-hosted-prod
variables:
DOCKER_BUILDKIT: "1"
IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest"
DEPLOY_DIR: "/opt/deploy/lti"
COMPOSE_FILE: "docker-compose.yaml"
# =========================
# BUILD (AUTO)
# =========================
build_production:
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
echo "✅ Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f Dockerfile .
echo "✅ Push image: $IMAGE_NAME"
docker push "$IMAGE_NAME"
echo "✅ Tag latest: $IMAGE_LATEST"
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (PRODUCTION)
# =========================
migrate_production:
stage: migrate
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
- job: build_production
artifacts: false
script: |
set -e
echo "✅ Running migrations (production) ..."
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
set -a
. ./.env
set +a
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)
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
# NOTE: pastikan nama servicenya benar untuk production (ini sebelumnya masih stg-*)
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
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 "✅ 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"
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"
# =========================
# DEPLOY (AUTO)
# =========================
deploy_production:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
- job: build_production
artifacts: false
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_production:
stage: seed
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: manual
- when: never
script: |
set -e
cd "$DEPLOY_DIR"
test -f .env || (echo "❌ .env not found" && exit 1)
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
docker compose --env-file .env pull seed
docker compose --env-file .env run --rm seed
+164
View File
@@ -0,0 +1,164 @@
stages:
- build
- migrate
- deploy
- seed
default:
tags:
- self-hosted-stg
variables:
DOCKER_BUILDKIT: "1"
IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest"
DEPLOY_DIR: "/opt/deploy/stg-lti-api"
COMPOSE_FILE: "docker-compose.yaml"
# =========================
# BUILD (AUTO)
# =========================
build_staging:
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
echo "✅ Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f Dockerfile .
echo "✅ Push image: $IMAGE_NAME"
docker push "$IMAGE_NAME"
echo "✅ Tag latest: $IMAGE_LATEST"
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (AUTO)
# =========================
migrate_staging:
stage: migrate
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
needs:
- job: build_staging
artifacts: false
script: |
set -e
echo "✅ Running migrations (staging) ..."
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
set -a
. ./.env
set +a
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)
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
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"
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"
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"
# =========================
# DEPLOY (AUTO)
# =========================
deploy_staging:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
needs:
- job: migrate_staging
artifacts: false
- job: build_staging
artifacts: false
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_staging:
stage: seed
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: manual
- when: never
needs:
- job: deploy_staging
artifacts: false
allow_failure: false
script: |
set -e
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1)
test -f .env || (echo "❌ .env not found" && exit 1)
docker compose -f "$COMPOSE_FILE" pull seed || true
docker compose -f "$COMPOSE_FILE" run --rm seed
+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"
@@ -0,0 +1,308 @@
package repository
import (
"context"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type HppCostRepository interface {
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error)
GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error)
GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
}
type HppRepositoryImpl struct {
db *gorm.DB
}
func NewHppCostRepository(db *gorm.DB) HppCostRepository {
return &HppRepositoryImpl{db: db}
}
func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) {
var ids []uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Select("id").
Where("project_flock_id = ?", projectFlockId).
Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_budgets AS pb").
Select("COALESCE(SUM(pb.qty * pb.price), 0)").
Where("pb.project_flock_id = ?", projectFlockId).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("expense_nonstocks AS en").
Select("COALESCE(SUM(er.qty * er.price), 0)").
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("f.name = ?", utils.FlagEkspedisi).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
flags := []utils.FlagType{
utils.FlagOVK,
utils.FlagObat,
utils.FlagVitamin,
utils.FlagKimia,
}
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty), 0)").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
COALESCE(SUM(pc.usage_qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
ELSE 0
END), 0)`,
stockablePurchase, stockableTransferIn).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id", usableProjectChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ?", stockableTransferIn, stockableTransferIn, stockablePurchase).
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
if date == nil {
now := time.Now()
date = &now
}
var totals struct {
TotalPieces float64
TotalWeightKg float64
}
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg").
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
return totals.TotalPieces, totals.TotalWeightKg, nil
}
func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
ctx context.Context,
projectFlockKandangIDs []uint,
startDate *time.Time,
endDate *time.Time,
) (float64, float64, error) {
if endDate == nil {
now := time.Now()
endDate = &now
}
type subResult struct {
UsableID uint
MdpUsageQty float64
MdpWeight float64
}
subQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
DISTINCT sa.usable_id,
mdp.usage_qty AS mdp_usage_qty,
mdp.total_weight AS mdp_weight
`).
Joins("JOIN recording_eggs re ON re.recording_id = r.id").
Joins(
"JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?",
fifo.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(),
).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *endDate).
Where("mdp.delivery_date <= ?", *startDate)
var totals struct {
TotalPieces float64
TotalWeight float64
}
err := r.db.WithContext(ctx).
Table("(?) AS x", subQuery).
Select(`
COALESCE(SUM(x.mdp_usage_qty), 0) AS total_pieces,
COALESCE(SUM(x.mdp_weight), 0) AS total_weight
`).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
return totals.TotalPieces, totals.TotalWeight, nil
}
func (r *HppRepositoryImpl) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) {
var projectFlockID uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Select("project_flock_id").
Where("id = ?", projectFlockKandangId).
Scan(&projectFlockID).Error
if err != nil {
return 0, err
}
return projectFlockID, nil
}
func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) {
var summary struct {
ProjectFlockID uint
TotalQty float64
}
err := r.db.WithContext(ctx).
Table("laying_transfer_targets AS ltt").
Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty").
Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id").
Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId).
Group("lt.from_project_flock_id").
Scan(&summary).Error
if err != nil {
return 0, 0, err
}
return summary.ProjectFlockID, summary.TotalQty, nil
}
@@ -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 {
+136 -20
View File
@@ -25,6 +25,8 @@ type FifoService interface {
Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error)
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error
ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error)
} }
type fifoService struct { type fifoService struct {
@@ -95,12 +97,26 @@ type StockReplenishRequest struct {
Tx *gorm.DB Tx *gorm.DB
} }
type StockAdjustRequest struct {
StockableKey fifo.StockableKey
StockableID uint
ProductWarehouseID uint
Quantity float64
Note *string
Tx *gorm.DB
}
type PendingResolution struct { type PendingResolution struct {
UsableKey fifo.UsableKey UsableKey fifo.UsableKey
UsableID uint UsableID uint
Quantity float64 Quantity float64
} }
type PendingResolveRequest struct {
ProductWarehouseID uint
Tx *gorm.DB
}
type StockReplenishResult struct { type StockReplenishResult struct {
AddedQuantity float64 AddedQuantity float64
PendingResolved []PendingResolution PendingResolved []PendingResolution
@@ -138,6 +154,38 @@ type StockReleaseRequest struct {
Tx *gorm.DB Tx *gorm.DB
} }
func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return errors.New("stockable key and id are required")
}
if req.ProductWarehouseID == 0 {
return errors.New("product warehouse id is required")
}
if req.Quantity == 0 {
return nil
}
if req.Quantity > 0 {
return errors.New("quantity must be negative")
}
cfg, ok := fifo.Stockable(req.StockableKey)
if !ok {
return fmt.Errorf("stockable %q is not registered", req.StockableKey)
}
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil {
return err
}
return s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
req.ProductWarehouseID: req.Quantity,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
})
})
}
func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) { func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return nil, errors.New("stockable key and id are required") return nil, errors.New("stockable key and id are required")
@@ -185,6 +233,23 @@ func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest)
return result, nil return result, nil
} }
func (s *fifoService) ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error) {
if req.ProductWarehouseID == 0 {
return nil, errors.New("product warehouse id is required")
}
var resolved []PendingResolution
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
var err error
resolved, err = s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
return err
})
if err != nil {
return nil, err
}
return resolved, nil
}
func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) { func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) {
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return nil, errors.New("usable key and id are required") return nil, errors.New("usable key and id are required")
@@ -267,7 +332,7 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
} }
if reductionTarget > 0 { if reductionTarget > 0 {
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget) released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget, productWarehouseID)
if err != nil { if err != nil {
return err return err
} }
@@ -314,7 +379,7 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
} }
var usageDelta, pendingDelta float64 var usageDelta, pendingDelta float64
if ctxRow.UsageQty > 0 { if ctxRow.UsageQty > 0 {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil { if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty, ctxRow.ProductWarehouseID); err != nil {
return err return err
} }
usageDelta -= ctxRow.UsageQty usageDelta -= ctxRow.UsageQty
@@ -680,6 +745,7 @@ func (s *fifoService) releaseUsagePortion(
usableKey fifo.UsableKey, usableKey fifo.UsableKey,
usableID uint, usableID uint,
target float64, target float64,
expectedWarehouseID uint,
) (float64, error) { ) (float64, error) {
if target <= 0 { if target <= 0 {
return 0, nil return 0, nil
@@ -695,6 +761,18 @@ func (s *fifoService) releaseUsagePortion(
if len(allocations) == 0 { if len(allocations) == 0 {
return 0, nil return 0, nil
} }
for i := range allocations {
alloc := &allocations[i]
if expectedWarehouseID == 0 || alloc.ProductWarehouseId == expectedWarehouseID {
continue
}
if err := tx.Model(&entities.StockAllocation{}).
Where("id = ?", alloc.Id).
Update("product_warehouse_id", expectedWarehouseID).Error; err != nil {
return 0, err
}
alloc.ProductWarehouseId = expectedWarehouseID
}
var ( var (
remaining = target remaining = target
@@ -791,30 +869,30 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
cfg.Columns.CreatedAt, cfg.Columns.CreatedAt,
) )
var rows []struct { if cfg.Columns.CreatedAt == cfg.Columns.ID {
ID uint var rows []struct {
Pending float64 ID uint
CreatedAt time.Time Pending float64 `gorm:"column:pending_qty"`
} CreatedAt int64 `gorm:"column:created_at"`
}
query := tx.Table(cfg.Table). query := tx.Table(cfg.Table).
Select(selectStmt). Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)). Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable) Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil { if cfg.Scope != nil {
query = cfg.Scope(query) query = cfg.Scope(query)
} }
for _, order := range s.orderClauses(cfg.OrderBy) { for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order) query = query.Order(order)
} }
if err := query.Find(&rows).Error; err != nil { if err := query.Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
for _, row := range rows { for _, row := range rows {
if row.Pending <= 0 { if row.Pending <= 0 {
continue continue
@@ -824,9 +902,47 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
Config: cfg, Config: cfg,
UsableID: row.ID, UsableID: row.ID,
Pending: row.Pending, Pending: row.Pending,
CreatedAt: row.CreatedAt, CreatedAt: time.Unix(0, row.CreatedAt),
}) })
} }
} else {
var rows []struct {
ID uint
Pending float64 `gorm:"column:pending_qty"`
CreatedAt time.Time `gorm:"column:created_at"`
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: row.CreatedAt,
})
}
}
} }
if len(candidates) == 0 { if len(candidates) == 0 {
@@ -0,0 +1,272 @@
package service
import (
"context"
"math"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
)
type HppService interface {
CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error)
GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error)
GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error)
GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error)
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error)
}
type HppCostResponse struct {
Estimation HppCostDetail `json:"estimation"`
Real HppCostDetail `json:"real"`
}
type HppCostDetail struct {
HargaKg float64 `json:"harga_kg"`
HargaButir float64 `json:"harga_butir"`
Total float64 `json:"total"`
Kg float64 `json:"kg"`
Butir float64 `json:"butir"`
}
type hppService struct {
hppRepo commonRepo.HppCostRepository
}
func NewHppService(hppRepo commonRepo.HppCostRepository) HppService {
return &hppService{hppRepo: hppRepo}
}
func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) {
if date == nil {
now := time.Now()
date = &now
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, err
}
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
endOfDay := startOfDay.Add(24 * time.Hour)
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
if err != nil {
return nil, err
}
return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
}
func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
if s.hppRepo == nil {
return 0, nil
}
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date)
if err != nil {
return 0, err
}
ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date)
if err != nil {
return 0, err
}
return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil
}
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) {
// if date == nil {
// now := time.Now()
// date = &now
// }
costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return 0, err
}
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil
}
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
// if date == nil {
// now := time.Now()
// date = &now
// }
if s.hppRepo == nil {
return 0, nil
}
projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId)
if err != nil {
return 0, err
}
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
if err != nil {
return 0, err
}
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId)
if err != nil {
return 0, err
}
if eggProduksiPiecesFlock == 0 {
return 0, nil
}
return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil
}
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
// if endDate == nil {
// now := time.Now()
// endDate = &now
// }
if s.hppRepo == nil {
return 0, nil
}
sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing)
if err != nil {
return 0, err
}
if totalPopulationFlockGrowing == 0 {
return 0, nil
}
totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, endDate)
if err != nil {
return 0, err
}
return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil
}
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
if s.hppRepo == nil {
return &HppCostResponse{}, nil
}
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return nil, err
}
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
if err != nil {
return nil, err
}
estimation := HppCostDetail{
Total: totalProductionCost,
Kg: estimWeightKg,
Butir: estimPieces,
}
if estimWeightKg > 0 {
estimation.HargaKg = roundToTwoDecimals(totalProductionCost / estimWeightKg)
}
if estimPieces > 0 {
estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces)
}
real := HppCostDetail{
Total: totalProductionCost,
Kg: realWeightKg,
Butir: realPieces,
}
if realWeightKg > 0 {
real.HargaKg = roundToTwoDecimals(totalProductionCost / realWeightKg)
}
if realPieces > 0 {
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
}
return &HppCostResponse{
Estimation: estimation,
Real: real,
}, nil
}
func roundToTwoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
+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,3 @@
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS pending_qty,
DROP COLUMN IF EXISTS source_product_warehouse_id;
@@ -0,0 +1,17 @@
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS pending_qty numeric(15,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS source_product_warehouse_id bigint;
UPDATE recording_depletions rd
SET source_product_warehouse_id = src.product_warehouse_id
FROM recordings r
JOIN LATERAL (
SELECT pfp.product_warehouse_id
FROM project_chickins pc
JOIN project_flock_populations pfp ON pfp.project_chickin_id = pc.id
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
ORDER BY pfp.created_at ASC, pfp.id ASC
LIMIT 1
) AS src ON true
WHERE r.id = rd.recording_id
AND rd.source_product_warehouse_id IS NULL;
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE payments
ALTER COLUMN bank_id SET NOT NULL;
COMMIT;
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE payments
ALTER COLUMN bank_id DROP NOT NULL;
COMMIT;
@@ -0,0 +1,3 @@
ALTER TABLE adjustment_stocks ADD COLUMN stock_log_id INTEGER;
CREATE INDEX idx_adjustment_stocks_stock_log_id ON adjustment_stocks (stock_log_id);
@@ -0,0 +1 @@
ALTER TABLE adjustment_stocks DROP COLUMN IF EXISTS stock_log_id;
@@ -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 IF NOT EXISTS 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 $$;
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE adjustment_stocks
DROP COLUMN adj_number;
COMMIT;
@@ -0,0 +1,10 @@
BEGIN;
ALTER TABLE adjustment_stocks
ADD COLUMN adj_number VARCHAR(255);
UPDATE adjustment_stocks
SET adj_number = CONCAT('ADJ-', LPAD(id::text, 5, '0'))
WHERE adj_number IS NULL;
COMMIT;
@@ -0,0 +1,8 @@
-- Remove columns from marketing_products
ALTER TABLE marketing_products
DROP COLUMN IF EXISTS week,
DROP COLUMN IF EXISTS weight_per_convertion,
DROP COLUMN IF EXISTS convertion_unit;
-- Remove column from marketings
ALTER TABLE marketings DROP COLUMN IF EXISTS marketing_type;
@@ -0,0 +1,9 @@
-- Add marketing_type to marketings table
ALTER TABLE marketings
ADD COLUMN IF NOT EXISTS marketing_type VARCHAR(50);
-- Add convertion fields to marketing_products table
ALTER TABLE marketing_products
ADD COLUMN IF NOT EXISTS convertion_unit VARCHAR(20),
ADD COLUMN IF NOT EXISTS weight_per_convertion NUMERIC(15, 3),
ADD COLUMN IF NOT EXISTS week INTEGER;
@@ -0,0 +1,47 @@
BEGIN;
DO $$
DECLARE
fallback_fcr_id BIGINT;
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flocks'
AND column_name = 'fcr_id'
) THEN
ALTER TABLE project_flocks
ADD COLUMN fcr_id BIGINT;
END IF;
SELECT id INTO fallback_fcr_id
FROM fcrs
ORDER BY id ASC
LIMIT 1;
IF fallback_fcr_id IS NOT NULL THEN
UPDATE project_flocks
SET fcr_id = fallback_fcr_id
WHERE fcr_id IS NULL;
ALTER TABLE project_flocks
ALTER COLUMN fcr_id SET NOT NULL;
END IF;
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'project_flocks_fcr_id_fkey'
) THEN
ALTER TABLE project_flocks
DROP CONSTRAINT project_flocks_fcr_id_fkey;
END IF;
ALTER TABLE project_flocks
ADD CONSTRAINT project_flocks_fcr_id_fkey
FOREIGN KEY (fcr_id) REFERENCES fcrs(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END $$;
COMMIT;
@@ -0,0 +1,26 @@
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flocks'
AND column_name = 'fcr_id'
) THEN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'project_flocks_fcr_id_fkey'
) THEN
ALTER TABLE project_flocks
DROP CONSTRAINT project_flocks_fcr_id_fkey;
END IF;
ALTER TABLE project_flocks
DROP COLUMN fcr_id;
END IF;
END $$;
COMMIT;
@@ -0,0 +1,12 @@
BEGIN;
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS chk_recording_depletions_pending_zero;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS total_used_qty;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS usage_qty;
COMMIT;
@@ -0,0 +1,15 @@
BEGIN;
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS usage_qty numeric(15, 3) NOT NULL DEFAULT 0;
UPDATE recording_depletions
SET pending_qty = 0
WHERE pending_qty IS NULL OR pending_qty <> 0;
ALTER TABLE recording_depletions
ADD CONSTRAINT chk_recording_depletions_pending_zero
CHECK (pending_qty = 0);
COMMIT;
+25 -5
View File
@@ -74,7 +74,7 @@ func seedUsers(tx *gorm.DB) (map[string]uint, error) {
} }
func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) { func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) {
names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor"} names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor", "Butir"}
result := make(map[string]uint, len(names)) result := make(map[string]uint, len(names))
for _, name := range names { for _, name := range names {
@@ -235,7 +235,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Utuh", Name: "Telur Utuh",
Brand: "-", Brand: "-",
Sku: "4", Sku: "4",
Uom: "Gram", Uom: "Butir",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurUtuh}, Flags: []utils.FlagType{utils.FlagTelurUtuh},
@@ -245,7 +245,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Pecah", Name: "Telur Pecah",
Brand: "-", Brand: "-",
Sku: "5", Sku: "5",
Uom: "Gram", Uom: "Butir",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPecah}, Flags: []utils.FlagType{utils.FlagTelurPecah},
@@ -255,7 +255,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Putih", Name: "Telur Putih",
Brand: "-", Brand: "-",
Sku: "6", Sku: "6",
Uom: "Gram", Uom: "Butir",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPutih}, Flags: []utils.FlagType{utils.FlagTelurPutih},
@@ -265,12 +265,32 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Retak", Name: "Telur Retak",
Brand: "-", Brand: "-",
Sku: "7", Sku: "7",
Uom: "Gram", Uom: "Butir",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurRetak}, Flags: []utils.FlagType{utils.FlagTelurRetak},
IsVisible: false, IsVisible: false,
}, },
{
Name: "Telur Papacal",
Brand: "-",
Sku: "8",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelur},
IsVisible: false,
},
{
Name: "Telur Jumbo",
Brand: "-",
Sku: "9",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelur},
IsVisible: false,
},
} }
for _, seed := range seeds { for _, seed := range seeds {
+10 -21
View File
@@ -2,28 +2,17 @@ package entities
import "time" import "time"
// AdjustmentStock tracks FIFO allocation for stock adjustments
// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse
// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse
type AdjustmentStock struct { type AdjustmentStock struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
StockLogId uint `gorm:"column:stock_log_id;not null;index"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` TotalQty float64 `gorm:"column:total_qty;default:0"`
TotalUsed float64 `gorm:"column:total_used;default:0"`
UsageQty float64 `gorm:"column:usage_qty;default:0"`
PendingQty float64 `gorm:"column:pending_qty;default:0"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
// === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) ===
// Tracks stock added to warehouse via adjustment INCREASE
TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available
TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot
// === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) ===
// Tracks stock consumed from warehouse via adjustment DECREASE
UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed
PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock)
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
// Relations
StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
} }
+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
@@ -14,6 +14,7 @@ type Marketing struct {
SoDate time.Time `gorm:"type:date;not null"` SoDate time.Time `gorm:"type:date;not null"`
SalesPersonId uint `gorm:"not null"` SalesPersonId uint `gorm:"not null"`
Notes string `gorm:"type:text"` Notes string `gorm:"type:text"`
MarketingType string `gorm:"type:varchar(50)"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
+11 -8
View File
@@ -1,14 +1,17 @@
package entities package entities
type MarketingProduct struct { type MarketingProduct struct {
Id uint `gorm:"primaryKey;autoIncrement"` Id uint `gorm:"primaryKey;autoIncrement"`
MarketingId uint `gorm:"not null"` MarketingId uint `gorm:"not null"`
ProductWarehouseId uint `gorm:"not null"` ProductWarehouseId uint `gorm:"not null"`
Qty float64 `gorm:"type:numeric(15,3);not null"` Qty float64 `gorm:"type:numeric(15,3);not null"`
UnitPrice float64 `gorm:"type:numeric(15,3);not null"` ConvertionUnit *string `gorm:"type:varchar(20)"`
AvgWeight float64 `gorm:"type:numeric(15,3);not null"` WeightPerConvertion *float64 `gorm:"type:numeric(15,3)"`
TotalWeight float64 `gorm:"type:numeric(15,3);not null"` Week *int `gorm:"type:integer"`
TotalPrice float64 `gorm:"type:numeric(15,3);not null"` UnitPrice float64 `gorm:"type:numeric(15,3);not null"`
AvgWeight float64 `gorm:"type:numeric(15,3);not null"`
TotalWeight float64 `gorm:"type:numeric(15,3);not null"`
TotalPrice float64 `gorm:"type:numeric(15,3);not null"`
Marketing Marketing `gorm:"foreignKey:MarketingId;references:Id"` Marketing Marketing `gorm:"foreignKey:MarketingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
-2
View File
@@ -11,7 +11,6 @@ type ProjectFlock struct {
FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"` FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"`
AreaId uint `gorm:"not null"` AreaId uint `gorm:"not null"`
Category string `gorm:"type:varchar(20);not null"` Category string `gorm:"type:varchar(20);not null"`
FcrId uint `gorm:"not null"`
ProductionStandardId uint `gorm:"column:production_standard_id"` ProductionStandardId uint `gorm:"column:production_standard_id"`
LocationId uint `gorm:"not null"` LocationId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
@@ -20,7 +19,6 @@ type ProjectFlock struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Area Area `gorm:"foreignKey:AreaId;references:Id"` Area Area `gorm:"foreignKey:AreaId;references:Id"`
Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"`
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
+1
View File
@@ -21,6 +21,7 @@ type PurchaseItem struct {
Price float64 `gorm:"type:numeric(15,3);default:0"` Price float64 `gorm:"type:numeric(15,3);default:0"`
TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` TotalPrice float64 `gorm:"type:numeric(15,3);default:0"`
ExpenseNonstockId *uint64 ExpenseNonstockId *uint64
HasChickin bool `gorm:"-" json:"-"`
// Relations // Relations
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
+2
View File
@@ -12,7 +12,9 @@ type Recording struct {
RecordDatetime time.Time `gorm:"column:record_datetime;not null"` RecordDatetime time.Time `gorm:"column:record_datetime;not null"`
Day *int `gorm:"column:day"` Day *int `gorm:"column:day"`
TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"` TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"`
TotalDepletionCumQty *float64 `gorm:"-"`
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
DepletionRate *float64 `gorm:"-"`
CumIntake *int `gorm:"column:cum_intake"` CumIntake *int `gorm:"column:cum_intake"`
FcrValue *float64 `gorm:"column:fcr_value"` FcrValue *float64 `gorm:"column:fcr_value"`
TotalChickQty *float64 `gorm:"column:total_chick_qty"` TotalChickQty *float64 `gorm:"column:total_chick_qty"`
+7 -4
View File
@@ -1,10 +1,13 @@
package entities package entities
type RecordingDepletion struct { type RecordingDepletion struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"` RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Qty float64 `gorm:"column:qty;not null"` SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
Qty float64 `gorm:"column:qty;not null"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
+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"`
} }
@@ -28,10 +28,31 @@ func NewClosingController(closingService service.ClosingService, sapronakService
} }
func (u *ClosingController) GetAll(c *fiber.Ctx) error { func (u *ClosingController) GetAll(c *fiber.Ctx) error {
var projectStatus *int
if raw := c.Query("project_status"); raw != "" {
statusValue, err := strconv.Atoi(raw)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_status")
}
projectStatus = &statusValue
}
var locationID *uint
if raw := c.Query("location_id"); raw != "" {
locationValue, err := strconv.Atoi(raw)
if err != nil || locationValue <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id")
}
locationUint := uint(locationValue)
locationID = &locationUint
}
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: c.Query("search", ""),
ProjectStatus: projectStatus,
LocationID: locationID,
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -160,7 +181,7 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error {
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
Message: "Get closing penjualan successfully", Message: "Get closing penjualan successfully",
Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), Data: dto.ToPenjualanRealisasiResponseDTO(result),
}) })
} }
@@ -190,7 +211,7 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
Message: "Get closing penjualan by project flock kandang successfully", Message: "Get closing penjualan by project flock kandang successfully",
Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), Data: dto.ToPenjualanRealisasiResponseDTO(result),
}) })
} }
@@ -236,9 +257,10 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error {
} }
query := &validation.ClosingSapronakQuery{ query := &validation.ClosingSapronakQuery{
Type: strings.ToLower(c.Query("type")), Type: strings.ToLower(c.Query("type")),
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search"),
} }
if raw := c.Query("kandang_id"); raw != "" { if raw := c.Query("kandang_id"); raw != "" {
kandangInt, convErr := strconv.Atoi(raw) kandangInt, convErr := strconv.Atoi(raw)
@@ -277,6 +299,45 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error {
}) })
} }
func (u *ClosingController) GetClosingSapronakSummary(c *fiber.Ctx) error {
param := c.Params("projectFlockId")
id, err := strconv.Atoi(param)
if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId")
}
query := &validation.ClosingSapronakQuery{
Type: strings.ToLower(c.Query("type")),
Search: c.Query("search"),
}
if raw := c.Query("kandang_id"); raw != "" {
kandangInt, convErr := strconv.Atoi(raw)
if convErr != nil || kandangInt <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id")
}
kandangUint := uint(kandangInt)
query.KandangID = &kandangUint
}
if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing {
return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
}
result, err := u.ClosingService.GetClosingSapronakSummary(c, uint(id), query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Retrieved closing report (sapronak summary) successfully",
Data: result,
})
}
func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
param := c.Params("project_flock_id") param := c.Params("project_flock_id")
flag := c.Query("flag", "") flag := c.Query("flag", "")
@@ -286,12 +347,12 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
} }
result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag) result, productFlags, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
if err != nil { if err != nil {
return err return err
} }
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag) payload := dto.ToSapronakProjectAggregatedFromReports(result, flag, productFlags)
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{ JSON(response.Success{
@@ -316,12 +377,12 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
} }
result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag) result, productFlags, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
if err != nil { if err != nil {
return err return err
} }
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag) payload := dto.ToSapronakProjectAggregatedFromReport(result, flag, productFlags)
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{ JSON(response.Success{
+20 -20
View File
@@ -98,26 +98,26 @@ type ClosingEggSalesDTO struct {
} }
type ClosingPerformanceDTO struct { type ClosingPerformanceDTO struct {
Depletion float64 `json:"depletion"` Depletion float64 `json:"depletion"`
Age float64 `json:"age_day"` Age float64 `json:"age_day"`
MortalityStd float64 `json:"mor_std"` MortalityStd float64 `json:"mor_std"`
MortalityAct float64 `json:"mor_act"` MortalityAct float64 `json:"mor_act"`
DeffMortality float64 `json:"mor_diff"` DeffMortality float64 `json:"mor_diff"`
FcrStd float64 `json:"fcr_std"` FcrStd float64 `json:"fcr_std"`
FcrAct float64 `json:"fcr_act"` FcrAct float64 `json:"fcr_act"`
DeffFcr float64 `json:"fcr_diff"` DeffFcr float64 `json:"fcr_diff"`
AwgAct float64 `json:"awg_act"` AwgAct float64 `json:"awg_act"`
AwgStd float64 `json:"awg_std"` AwgStd float64 `json:"awg_std"`
FeedIntake float64 `json:"feed_intake"` FeedIntake float64 `json:"feed_intake"`
FeedIntakeStd float64 `json:"feed_intake_std"` FeedIntakeStd float64 `json:"feed_intake_std"`
HenDayAct *float64 `json:"hen_day_act,omitempty"` HenDayAct float64 `json:"hen_day_act,omitempty"`
HendayStd float64 `json:"hen_day_std"` HendayStd float64 `json:"hen_day_std"`
EggMass *float64 `json:"egg_mass,omitempty"` EggMass float64 `json:"egg_mass,omitempty"`
EggMassStd float64 `json:"egg_mass_std"` EggMassStd float64 `json:"egg_mass_std"`
EggWeight *float64 `json:"egg_weight,omitempty"` EggWeight float64 `json:"egg_weight,omitempty"`
EggWeightStd float64 `json:"egg_weight_std"` EggWeightStd float64 `json:"egg_weight_std"`
HenHouseAct *float64 `json:"hen_housed_act,omitempty"` HenHouseAct float64 `json:"hen_housed_act,omitempty"`
HenHouseStd float64 `json:"hen_housed_std"` HenHouseStd float64 `json:"hen_housed_std"`
} }
type ClosingSalesGroupDTO struct { type ClosingSalesGroupDTO struct {
@@ -1,8 +1,12 @@
package dto package dto
// === CLOSING KEUANGAN CODES === import (
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// Closing HPP Codes
type ClosingHPPCode string type ClosingHPPCode string
const ( const (
@@ -14,36 +18,30 @@ const (
HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI" HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI"
) )
// Closing Profit Loss Codes
type ClosingProfitLossCode string type ClosingProfitLossCode string
const ( const (
PLCodeSales ClosingProfitLossCode = "SALES" PLCodeSales ClosingProfitLossCode = "SALES"
PLCodeSapronak ClosingProfitLossCode = "SAPRONAK" PLCodeSapronak ClosingProfitLossCode = "SAPRONAK"
PLCodeOverhead ClosingProfitLossCode = "OVERHEAD" PLCodeOverhead ClosingProfitLossCode = "OVERHEAD"
PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI" PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI"
) )
// === NEW CLOSING KEUANGAN DTO ===
// FinancialMetrics represents financial metrics with per unit and total amounts
type FinancialMetrics struct { type FinancialMetrics struct {
RpPerBird float64 `json:"rp_per_bird"` RpPerBird float64 `json:"rp_per_bird"`
RpPerKg float64 `json:"rp_per_kg"` RpPerKg float64 `json:"rp_per_kg"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
} }
// HPPItem represents an item in HPP section
type HPPItem struct { type HPPItem struct {
ID uint `json:"id"` ID uint `json:"id"`
Category string `json:"category"` // "purchase" or "overhead" Category string `json:"category"`
Code string `json:"code"` // "PAKAN", "OVK", "DOC", "EKSPEDISI" Code string `json:"code"`
Label string `json:"label"` Label string `json:"label"`
Budgeting FinancialMetrics `json:"budgeting"` Budgeting FinancialMetrics `json:"budgeting"`
Realization FinancialMetrics `json:"realization"` Realization FinancialMetrics `json:"realization"`
} }
// HPPSummary represents summary for HPP section
type HPPSummary struct { type HPPSummary struct {
Label string `json:"label"` Label string `json:"label"`
Budgeting FinancialMetrics `json:"budgeting"` Budgeting FinancialMetrics `json:"budgeting"`
@@ -52,52 +50,41 @@ type HPPSummary struct {
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` EggRealization *FinancialMetrics `json:"egg_realization,omitempty"`
} }
// HPPSection represents HPP data section
type HPPSection struct { type HPPSection struct {
Items []HPPItem `json:"items"` Items []HPPItem `json:"items"`
Summary HPPSummary `json:"summary"` Summary HPPSummary `json:"summary"`
} }
// ProfitLossItem represents an item in Profit & Loss section
type ProfitLossItem struct { type ProfitLossItem struct {
Code string `json:"code"` // "SALES", "PURCHASE_DOC", "OVERHEAD", "EKSPEDISI" Code string `json:"code"`
Label string `json:"label"` Label string `json:"label"`
Type string `json:"type"` // "income", "purchase", "overhead" Type string `json:"type"`
RpPerBird float64 `json:"rp_per_bird"` RpPerBird float64 `json:"rp_per_bird"`
RpPerKg float64 `json:"rp_per_kg"` RpPerKg float64 `json:"rp_per_kg"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
} }
// ProfitLossSummary represents summary for Profit & Loss section
type ProfitLossSummary struct { type ProfitLossSummary struct {
GrossProfit FinancialMetrics `json:"gross_profit"` GrossProfit FinancialMetrics `json:"gross_profit"`
SubTotal FinancialMetrics `json:"sub_total"` SubTotal FinancialMetrics `json:"sub_total"`
NetProfit FinancialMetrics `json:"net_profit"` NetProfit FinancialMetrics `json:"net_profit"`
} }
// ProfitLossSection represents Profit & Loss data section
type ProfitLossSection struct { type ProfitLossSection struct {
Items []ProfitLossItem `json:"items"` Items []ProfitLossItem `json:"items"`
Summary ProfitLossSummary `json:"summary"` Summary ProfitLossSummary `json:"summary"`
} }
// ClosingKeuanganData represents the main data structure
type ClosingKeuanganData struct { type ClosingKeuanganData struct {
HPP HPPSection `json:"hpp"` HPP HPPSection `json:"hpp"`
ProfitLoss ProfitLossSection `json:"profit_loss"` ProfitLoss ProfitLossSection `json:"profit_loss"`
} }
type MetricsCalculator struct {
// ClosingKeuanganResponse represents the full API response TotalPopulation float64
type ClosingKeuanganResponse struct { ActualPopulation float64
Code int `json:"code"` TotalWeightProduced float64
Status string `json:"status"`
Message string `json:"message"`
Data ClosingKeuanganData `json:"data"`
} }
// === MAPPER FUNCTIONS ===
// ToFinancialMetrics creates FinancialMetrics from values
func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
return FinancialMetrics{ return FinancialMetrics{
RpPerBird: rpPerBird, RpPerBird: rpPerBird,
@@ -106,7 +93,6 @@ func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
} }
} }
// ToHPPItem creates HPP item
func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem { func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem {
return HPPItem{ return HPPItem{
ID: id, ID: id,
@@ -118,7 +104,6 @@ func ToHPPItem(id uint, category, code, label string, budgeting, realization Fin
} }
} }
// ToHPPSummary creates HPP summary
func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary { func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary {
return HPPSummary{ return HPPSummary{
Label: label, Label: label,
@@ -129,7 +114,6 @@ func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudg
} }
} }
// ToHPPSection creates HPP section
func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection { func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection {
return HPPSection{ return HPPSection{
Items: items, Items: items,
@@ -137,7 +121,6 @@ func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection {
} }
} }
// ToProfitLossItem creates Profit & Loss item
func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem { func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem {
return ProfitLossItem{ return ProfitLossItem{
Code: code, Code: code,
@@ -149,7 +132,6 @@ func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount f
} }
} }
// ToProfitLossSummary creates Profit & Loss summary
func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary { func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary {
return ProfitLossSummary{ return ProfitLossSummary{
GrossProfit: grossProfit, GrossProfit: grossProfit,
@@ -158,7 +140,6 @@ func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) Prof
} }
} }
// ToProfitLossSection creates Profit & Loss section
func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection { func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection {
return ProfitLossSection{ return ProfitLossSection{
Items: items, Items: items,
@@ -166,7 +147,6 @@ func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) Prof
} }
} }
// ToClosingKeuanganData creates complete closing keuangan data
func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData { func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData {
return ClosingKeuanganData{ return ClosingKeuanganData{
HPP: hpp, HPP: hpp,
@@ -174,12 +154,72 @@ func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) Closing
} }
} }
// ToSuccessClosingKeuanganResponse creates success response func (mc *MetricsCalculator) CalculateMetrics(amount float64) (rpPerBird, rpPerKg float64) {
func ToSuccessClosingKeuanganResponse(data ClosingKeuanganData) ClosingKeuanganResponse { if mc.ActualPopulation > 0 {
return ClosingKeuanganResponse{ rpPerBird = amount / mc.ActualPopulation
Code: 200,
Status: "success",
Message: "Get closing keuangan successfully",
Data: data,
} }
if mc.TotalWeightProduced > 0 {
rpPerKg = amount / mc.TotalWeightProduced
}
return
}
func (mc *MetricsCalculator) CalculateProfitLossMetrics(amount float64) (rpPerBird, rpPerKg float64) {
if mc.TotalPopulation > 0 {
rpPerBird = amount / mc.TotalPopulation
}
if mc.TotalWeightProduced > 0 {
rpPerKg = amount / mc.TotalWeightProduced
}
return
}
type ProductFilter struct {
ProjectFlockCategory string
}
func (pf *ProductFilter) IsEggProduct(product entity.Product) bool {
for _, flag := range product.Flags {
flagName := strings.ToUpper(flag.Name)
if flagName == string(utils.FlagTelur) ||
flagName == string(utils.FlagTelurUtuh) ||
flagName == string(utils.FlagTelurPecah) ||
flagName == string(utils.FlagTelurPutih) ||
flagName == string(utils.FlagTelurRetak) {
return true
}
}
return false
}
func (pf *ProductFilter) IsChickenProduct(product entity.Product) bool {
for _, flag := range product.Flags {
flagName := strings.ToUpper(flag.Name)
if flagName == string(utils.FlagAyamAfkir) ||
flagName == string(utils.FlagAyamCulling) ||
flagName == string(utils.FlagAyamMati) {
return true
}
}
return false
}
func (pf *ProductFilter) ShouldIncludeProduct(product entity.Product) bool {
if pf.ProjectFlockCategory == string(utils.ProjectFlockCategoryLaying) {
return pf.IsEggProduct(product)
}
return pf.IsChickenProduct(product) || (!pf.IsEggProduct(product) && !pf.IsChickenProduct(product))
}
func (pf *ProductFilter) FilterDeliveryProducts(deliveries []entity.MarketingDeliveryProduct) []entity.MarketingDeliveryProduct {
filtered := make([]entity.MarketingDeliveryProduct, 0)
for _, delivery := range deliveries {
if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 {
continue
}
if pf.ShouldIncludeProduct(delivery.MarketingProduct.ProductWarehouse.Product) {
filtered = append(filtered, delivery)
}
}
return filtered
} }
@@ -8,34 +8,54 @@ import (
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
// === Response DTO === // === Response DTO ===
type SalesDTO struct { type SalesDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
RealizationDate time.Time `json:"realization_date"` RealizationDate time.Time `json:"realization_date"`
Age int `json:"age"` Age int `json:"age"`
DoNumber string `json:"do_number"` Week int `json:"week"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"` DoNumber string `json:"do_number"`
Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Qty float64 `json:"qty"` Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"`
Weight float64 `json:"weight"` Qty float64 `json:"qty"`
AvgWeight float64 `json:"avg_weight"` Weight float64 `json:"weight"`
Price float64 `json:"price"` AvgWeight float64 `json:"avg_weight"`
TotalPrice float64 `json:"total_price"` SalesPrice float64 `json:"sales_price"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` TotalSalesPrice float64 `json:"total_sales_price"`
PaymentStatus string `json:"payment_status"` ActualPrice float64 `json:"actual_price"`
TotalActualPrice float64 `json:"total_actual_price"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
}
type SummaryDTO struct {
TotalSalesPrice float64 `json:"total_sales_price"`
AvgSalesPrice float64 `json:"avg_sales_price"`
TotalActualPrice float64 `json:"total_actual_price"`
AvgActualPrice float64 `json:"avg_actual_price"`
} }
type PenjualanRealisasiResponseDTO struct { type PenjualanRealisasiResponseDTO struct {
Sales []SalesDTO `json:"sales"` Sales []SalesDTO `json:"sales"`
Summary SummaryDTO `json:"summary"`
} }
// === Mapper Functions === // === Mapper Functions ===
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
age := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate) 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, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
var product *productDTO.ProductRelationDTO var product *productDTO.ProductRelationDTO
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
@@ -63,19 +83,69 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id)
return SalesDTO{ return SalesDTO{
Id: e.Id, Id: e.Id,
RealizationDate: realizationDate, RealizationDate: realizationDate,
Age: age, Age: ageInDay,
DoNumber: doNumber, Week: ageInWeeks,
Product: product, DoNumber: doNumber,
Customer: customer, Product: product,
Qty: e.UsageQty, Customer: customer,
Weight: e.TotalWeight, Qty: e.UsageQty,
AvgWeight: e.AvgWeight, Weight: e.TotalWeight,
Price: e.UnitPrice, AvgWeight: e.AvgWeight,
TotalPrice: e.TotalPrice, SalesPrice: e.MarketingProduct.UnitPrice,
Kandang: kandang, TotalSalesPrice: e.MarketingProduct.TotalPrice,
PaymentStatus: "Paid", ActualPrice: e.UnitPrice,
TotalActualPrice: e.TotalPrice,
Kandang: kandang,
}
}
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
count := len(e)
if count == 0 {
return SummaryDTO{
TotalSalesPrice: 0,
TotalActualPrice: 0,
AvgSalesPrice: 0,
AvgActualPrice: 0,
}
}
for _, item := range e {
totalSalesPrice += item.MarketingProduct.TotalPrice
totalActualPrice += item.TotalPrice
sumSales += item.MarketingProduct.UnitPrice
sumActual += item.UnitPrice
}
return SummaryDTO{
TotalSalesPrice: totalSalesPrice,
TotalActualPrice: totalActualPrice,
AvgSalesPrice: sumSales / float64(count),
AvgActualPrice: sumActual / float64(count),
} }
} }
@@ -87,28 +157,35 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO {
return result return result
} }
func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO {
return PenjualanRealisasiResponseDTO{ return PenjualanRealisasiResponseDTO{
Sales: ToSalesDTOs(e),
Sales: ToSalesDTOs(e), Summary: ToSummaryDto(e),
} }
} }
func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int { func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) {
if len(realisasi) > 0 {
for _, item := range realisasi {
if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period
}
}
}
return 0
}
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0 return 0, 0
}
for _, flag := range productFlags {
if flag == string(utils.FlagOVK) ||
flag == string(utils.FlagPakan) ||
flag == string(utils.FlagPreStarter) ||
flag == string(utils.FlagStarter) ||
flag == string(utils.FlagFinisher) ||
flag == string(utils.FlagObat) ||
flag == string(utils.FlagVitamin) ||
flag == string(utils.FlagKimia) ||
flag == string(utils.FlagEkspedisi) ||
flag == string(utils.FlagTelur) ||
flag == string(utils.FlagTelurUtuh) ||
flag == string(utils.FlagTelurPecah) ||
flag == string(utils.FlagTelurPutih) ||
flag == string(utils.FlagTelurRetak) {
return 0, 0
}
} }
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
@@ -118,7 +195,20 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, de
} }
} }
ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24) diff := deliveryDate.Sub(earliestChickinDate)
ageInWeeks := ageInDays / 7 ageInDays := int(diff.Hours() / 24)
return ageInWeeks
var ageInWeeks int
if ageInDays <= 0 {
ageInWeeks = 0
} else {
if category == string(utils.ProjectFlockCategoryLaying) {
ageInDays = ageInDays + 119
ageInWeeks = ((ageInDays - 1) / 7) + 1
} else {
ageInWeeks = ((ageInDays - 1) / 7) + 1
}
}
return ageInDays, ageInWeeks
} }
@@ -1,6 +1,7 @@
package dto package dto
import ( import (
"sort"
"strings" "strings"
"time" "time"
) )
@@ -114,9 +115,20 @@ type ClosingSapronakDTO struct {
OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"` OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"`
} }
type ClosingSapronakSummaryItemDTO struct {
Category string `json:"category"`
TotalQty int64 `json:"total_qty"`
Uom UomSummaryDTO `json:"uom"`
}
type UomSummaryDTO struct {
ID uint `json:"id"`
Name string `json:"name"`
}
// === Mapper Functions for Aggregated Sapronak Response === // === Mapper Functions for Aggregated Sapronak Response ===
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{} result := SapronakProjectAggregatedDTO{}
if len(reports) == 0 { if len(reports) == 0 {
@@ -124,10 +136,10 @@ func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag st
} }
rep := reports[0] rep := reports[0]
return ToSapronakProjectAggregatedFromReport(&rep, flag) return ToSapronakProjectAggregatedFromReport(&rep, flag, productFlags)
} }
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{} result := SapronakProjectAggregatedDTO{}
if report == nil { if report == nil {
@@ -164,6 +176,53 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
return t.Format("02-Jan-2006") return t.Format("02-Jan-2006")
} }
flagOrder := map[string]int{
"DOC": 0,
"PAKAN": 0,
"OVK": 0,
"PULLET": 0,
}
buildFlagList := func(productID uint, fallback string) string {
rawFlags := productFlags[productID]
if len(rawFlags) == 0 {
if fallback == "" {
return ""
}
return fallback
}
seen := make(map[string]struct{}, len(rawFlags))
ordered := make([]string, 0, len(rawFlags))
for _, f := range rawFlags {
flagName := strings.ToUpper(strings.TrimSpace(f))
if flagName == "" {
continue
}
if _, ok := seen[flagName]; ok {
continue
}
seen[flagName] = struct{}{}
ordered = append(ordered, flagName)
}
sort.SliceStable(ordered, func(i, j int) bool {
li := ordered[i]
lj := ordered[j]
ri, iok := flagOrder[li]
rj, jok := flagOrder[lj]
if iok != jok {
if iok {
return true
}
return false
}
if iok && jok && ri != rj {
return ri < rj
}
return li < lj
})
return strings.Join(ordered, " ")
}
for _, group := range report.Groups { for _, group := range report.Groups {
flagKey := normalizeFlag(group.Flag) flagKey := normalizeFlag(group.Flag)
ptr := byFlag[flagKey] ptr := byFlag[flagKey]
@@ -185,13 +244,17 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
for idx, item := range group.Items { for idx, item := range group.Items {
productKey := strings.ToUpper(flagKey + "|" + item.ProductName) refKey := strings.TrimSpace(item.NoReferensi)
productKey := strings.ToUpper(flagKey + "|" + item.ProductName + "|" + refKey)
if refKey == "" {
productKey = strings.ToUpper(flagKey + "|" + item.ProductName + "|" + formatDate(item.Tanggal))
}
baseRow := SapronakCategoryRowDTO{ baseRow := SapronakCategoryRowDTO{
ID: idx + 1, ID: idx + 1,
Date: formatDate(item.Tanggal), Date: formatDate(item.Tanggal),
ReferenceNumber: item.NoReferensi, ReferenceNumber: item.NoReferensi,
Description: item.ProductName, Description: item.ProductName,
ProductCategory: item.ProductName, ProductCategory: buildFlagList(item.ProductID, flagKey),
UnitPrice: item.Harga, UnitPrice: item.Harga,
Notes: "-", Notes: "-",
} }
@@ -201,18 +264,51 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
switch strings.ToLower(item.JenisTransaksi) { switch strings.ToLower(item.JenisTransaksi) {
case "pembelian", "adjustment masuk", "mutasi masuk": case "pembelian", "adjustment masuk", "mutasi masuk":
row.QtyIn += item.QtyMasuk row.QtyIn += item.QtyMasuk
row.TotalAmount += item.Nilai if item.Tanggal != nil {
case "pemakaian", "adjustment keluar": row.Date = formatDate(item.Tanggal)
}
if row.UnitPrice == 0 {
if item.QtyMasuk > 0 && item.Nilai > 0 {
row.UnitPrice = item.Nilai / item.QtyMasuk
} else if item.Harga > 0 {
row.UnitPrice = item.Harga
}
}
if strings.ToLower(item.JenisTransaksi) == "mutasi masuk" {
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
if strings.HasPrefix(ref, "TL-") {
row.Notes = "TRANSFER LAYING"
} else if strings.HasPrefix(ref, "ST-") {
row.Notes = "TRANSFER STOCK"
}
}
case "pemakaian":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
row.QtyUsed += item.QtyKeluar row.QtyUsed += item.QtyKeluar
case "mutasi keluar": row.TotalAmount += item.QtyKeluar * price
case "adjustment keluar", "mutasi keluar", "penjualan":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
row.QtyOut += item.QtyKeluar row.QtyOut += item.QtyKeluar
if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" {
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
if strings.HasPrefix(ref, "TL-") {
row.Notes = "TRANSFER LAYING"
} else if strings.HasPrefix(ref, "ST-") {
row.Notes = "TRANSFER STOCK"
}
}
default: default:
row.QtyIn += item.QtyMasuk row.QtyIn += item.QtyMasuk
row.TotalAmount += item.Nilai row.TotalAmount += item.Nilai
} if row.QtyIn > 0 {
row.UnitPrice = row.TotalAmount / row.QtyIn
if row.QtyIn > 0 { }
row.UnitPrice = row.TotalAmount / row.QtyIn
} }
} }
@@ -233,8 +329,8 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
total += r.TotalAmount total += r.TotalAmount
} }
avg := 0.0 avg := 0.0
if qtyIn > 0 { if qtyUsed > 0 {
avg = total / qtyIn avg = total / qtyUsed
} }
cat.Total = SapronakCategoryTotalDTO{ cat.Total = SapronakCategoryTotalDTO{
Label: label, Label: label,
+3 -2
View File
@@ -25,7 +25,6 @@ type ClosingModule struct{}
func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
closingRepo := rClosing.NewClosingRepository(db) closingRepo := rClosing.NewClosingRepository(db)
closingKeuanganRepo := rClosing.NewClosingKeuanganRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
@@ -40,9 +39,11 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
purchaseRepo := rPurchase.NewPurchaseRepository(db) purchaseRepo := rPurchase.NewPurchaseRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo) approvalService := commonSvc.NewApprovalService(approvalRepo)
hppCostRepo := commonRepo.NewHppCostRepository(db)
hppService := commonSvc.NewHppService(hppCostRepo)
closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate) closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate)
closingKeuanganService := sClosing.NewClosingKeuanganService(closingKeuanganRepo, projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo) closingKeuanganService := sClosing.NewClosingKeuanganService(projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo, hppService, hppCostRepo)
sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -17,13 +17,13 @@ import (
type ClosingRepository interface { type ClosingRepository interface {
repository.BaseRepository[entity.ProjectFlock] repository.BaseRepository[entity.ProjectFlock]
GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error)
GetSapronakSummary(ctx context.Context, params SapronakQueryParams) ([]SapronakSummaryRow, error)
SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error)
SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error)
SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error)
SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error)
GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error)
GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error)
FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error)
FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error)
@@ -31,8 +31,11 @@ type ClosingRepository interface {
FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error)
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
} }
@@ -59,10 +62,18 @@ type SapronakRow struct {
DestinationWarehouse string `gorm:"column:destination_warehouse"` DestinationWarehouse string `gorm:"column:destination_warehouse"`
Destination string `gorm:"column:destination"` Destination string `gorm:"column:destination"`
Quantity float64 `gorm:"column:quantity"` Quantity float64 `gorm:"column:quantity"`
UnitID uint `gorm:"column:unit_id"`
Unit string `gorm:"column:unit"` Unit string `gorm:"column:unit"`
Notes string `gorm:"column:notes"` Notes string `gorm:"column:notes"`
} }
type SapronakSummaryRow struct {
Category string `gorm:"column:category"`
TotalQty int64 `gorm:"column:total_qty"`
UomID uint `gorm:"column:uom_id"`
UomName string `gorm:"column:uom_name"`
}
type ExpeditionHPPRow struct { type ExpeditionHPPRow struct {
SupplierName string `gorm:"column:supplier_name"` SupplierName string `gorm:"column:supplier_name"`
TotalAmount float64 `gorm:"column:total_amount"` TotalAmount float64 `gorm:"column:total_amount"`
@@ -74,6 +85,7 @@ type SapronakQueryParams struct {
ProjectFlockKandangIDs []uint ProjectFlockKandangIDs []uint
Limit int Limit int
Offset int Offset int
Search string
} }
func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) {
@@ -89,12 +101,12 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
if len(params.WarehouseIDs) == 0 { if len(params.WarehouseIDs) == 0 {
return []SapronakRow{}, 0, nil return []SapronakRow{}, 0, nil
} }
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs) args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing: case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 { if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL) unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
args = append(args, params.WarehouseIDs) args = append(args, params.WarehouseIDs, params.WarehouseIDs)
} }
if len(params.ProjectFlockKandangIDs) > 0 { if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
@@ -109,14 +121,36 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
unionSQL := strings.Join(unionParts, " UNION ALL ") unionSQL := strings.Join(unionParts, " UNION ALL ")
search := strings.TrimSpace(params.Search)
searchClause := ""
var searchArgs []any
if search != "" {
searchClause = `
WHERE (
reference_number ILIKE ?
OR product_name ILIKE ?
OR product_category ILIKE ?
OR source_warehouse ILIKE ?
OR destination_warehouse ILIKE ?
OR CAST(quantity AS TEXT) ILIKE ?
OR unit ILIKE ?
OR notes ILIKE ?
OR transaction_type ILIKE ?
)`
like := "%" + search + "%"
searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like)
}
var totalResults int64 var totalResults int64
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined", unionSQL) countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause)
if err := db.Raw(countSQL, args...).Scan(&totalResults).Error; err != nil { countArgs := append(append([]any{}, args...), searchArgs...)
if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
dataArgs := append(append([]any{}, args...), params.Limit, params.Offset) dataArgs := append(append([]any{}, args...), searchArgs...)
dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL) dataArgs = append(dataArgs, params.Limit, params.Offset)
dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause)
var rows []SapronakRow var rows []SapronakRow
if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil {
@@ -126,6 +160,79 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
return rows, totalResults, nil return rows, totalResults, nil
} }
func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params SapronakQueryParams) ([]SapronakSummaryRow, error) {
db := r.DB().WithContext(ctx)
var (
unionParts []string
args []any
)
switch params.Type {
case validation.SapronakTypeIncoming:
if len(params.WarehouseIDs) == 0 {
return []SapronakSummaryRow{}, nil
}
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
}
if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
args = append(args, params.ProjectFlockKandangIDs)
}
if len(unionParts) == 0 {
return []SapronakSummaryRow{}, nil
}
default:
return nil, fmt.Errorf("invalid sapronak type: %s", params.Type)
}
unionSQL := strings.Join(unionParts, " UNION ALL ")
search := strings.TrimSpace(params.Search)
searchClause := ""
var searchArgs []any
if search != "" {
searchClause = `
WHERE (
reference_number ILIKE ?
OR product_name ILIKE ?
OR product_category ILIKE ?
OR source_warehouse ILIKE ?
OR destination_warehouse ILIKE ?
OR CAST(quantity AS TEXT) ILIKE ?
OR unit ILIKE ?
OR notes ILIKE ?
OR transaction_type ILIKE ?
)`
like := "%" + search + "%"
searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like)
}
querySQL := fmt.Sprintf(`
SELECT
product_category AS category,
CAST(COALESCE(SUM(quantity), 0) AS BIGINT) AS total_qty,
unit_id AS uom_id,
unit AS uom_name
FROM (%s) AS combined%s
GROUP BY product_category, unit_id, unit
ORDER BY product_category ASC, unit ASC
`, unionSQL, searchClause)
queryArgs := append(append([]any{}, args...), searchArgs...)
var rows []SapronakSummaryRow
if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) { func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) {
if len(projectFlockKandangIDs) == 0 { if len(projectFlockKandangIDs) == 0 {
return 0, 0, nil return 0, 0, nil
@@ -248,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
@@ -284,22 +392,6 @@ func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFla
return agg.TotalQty, nil return agg.TotalQty, nil
} }
func (r *ClosingRepositoryImpl) GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error) {
if fcrID == 0 {
return []entity.FcrStandard{}, nil
}
var standards []entity.FcrStandard
if err := r.DB().WithContext(ctx).
Where("fcr_id = ?", fcrID).
Order("weight ASC").
Find(&standards).Error; err != nil {
return nil, err
}
return standards, nil
}
func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) { func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) {
db := r.DB().WithContext(ctx) db := r.DB().WithContext(ctx)
@@ -347,7 +439,7 @@ SELECT
COALESCE(pi.received_date, '1970-01-01') AS sort_date, COALESCE(pi.received_date, '1970-01-01') AS sort_date,
COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text, COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(p.po_number, '') AS reference_number, COALESCE(p.po_number, '') AS reference_number,
'Purchase' AS transaction_type, 'Pembelian' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -379,6 +471,7 @@ SELECT
w.name AS destination_warehouse, w.name AS destination_warehouse,
'' AS destination, '' AS destination,
pi.total_qty AS quantity, pi.total_qty AS quantity,
u.id AS unit_id,
u.name AS unit, u.name AS unit,
COALESCE(p.notes, '') AS notes COALESCE(p.notes, '') AS notes
FROM purchase_items pi FROM purchase_items pi
@@ -395,7 +488,7 @@ SELECT
st.transfer_date AS sort_date, st.transfer_date AS sort_date,
TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text,
st.movement_number AS reference_number, st.movement_number AS reference_number,
'Internal Transfer In' AS transaction_type, 'Mutasi' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -427,8 +520,9 @@ SELECT
COALESCE(tw.name, '') AS destination_warehouse, COALESCE(tw.name, '') AS destination_warehouse,
'' AS destination, '' AS destination,
std.usage_qty AS quantity, std.usage_qty AS quantity,
u.id AS unit_id,
u.name AS unit, u.name AS unit,
'Stock Refill' AS notes st.reason AS notes
FROM stock_transfer_details std FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
@@ -438,13 +532,63 @@ JOIN uoms u ON u.id = prod.uom_id
WHERE st.to_warehouse_id IN ? WHERE st.to_warehouse_id IN ?
` `
sapronakIncomingAdjustmentsSQL = `
SELECT
CAST(ast.id AS BIGINT) AS id,
ast.created_at AS sort_date,
COALESCE(TO_CHAR(ast.created_at, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(ast.adj_number, '') AS reference_number,
'Adjustment stock' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
'-' AS source_warehouse,
COALESCE(w.name, '') AS destination_warehouse,
'' AS destination,
COALESCE(ast.total_qty, 0) AS quantity,
u.id AS unit_id,
u.name AS unit,
'-' AS notes
FROM adjustment_stocks ast
JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id
WHERE pw.warehouse_id IN ?
AND COALESCE(ast.total_qty, 0) <> 0
`
sapronakOutgoingTransfersSQL = ` sapronakOutgoingTransfersSQL = `
SELECT SELECT
CAST(st.id AS BIGINT) AS id, CAST(st.id AS BIGINT) AS id,
st.transfer_date AS sort_date, st.transfer_date AS sort_date,
TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text,
st.movement_number AS reference_number, st.movement_number AS reference_number,
'Internal Transfer Out' AS transaction_type, 'Mutasi' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -476,8 +620,9 @@ SELECT
COALESCE(tw.name, '') AS destination_warehouse, COALESCE(tw.name, '') AS destination_warehouse,
'' AS destination, '' AS destination,
std.usage_qty AS quantity, std.usage_qty AS quantity,
u.id AS unit_id,
u.name AS unit, u.name AS unit,
'Transfer to other unit' AS notes st.reason AS notes
FROM stock_transfer_details std FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
@@ -487,13 +632,70 @@ JOIN uoms u ON u.id = prod.uom_id
WHERE st.from_warehouse_id IN ? WHERE st.from_warehouse_id IN ?
` `
sapronakOutgoingAdjustmentsSQL = `
SELECT
CAST(ast.id AS BIGINT) AS id,
ast.created_at AS sort_date,
COALESCE(TO_CHAR(ast.created_at, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(ast.adj_number, '') AS reference_number,
'Adjustment stock' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
COALESCE(w.name, '') AS source_warehouse,
'-' AS destination_warehouse,
'' AS destination,
COALESCE(ast.usage_qty, 0) AS quantity,
u.id AS unit_id,
u.name AS unit,
'-' AS notes
FROM adjustment_stocks ast
JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id
WHERE pw.warehouse_id IN ?
AND COALESCE(ast.usage_qty, 0) <> 0
AND EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_id = pw.product_id
AND f.flagable_type = 'products'
AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK')
)
`
sapronakOutgoingMarketingsSQL = ` sapronakOutgoingMarketingsSQL = `
SELECT SELECT
CAST(mp.id AS BIGINT) AS id, CAST(mp.id AS BIGINT) AS id,
m.so_date AS sort_date, m.so_date AS sort_date,
TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text, TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text,
m.so_number AS reference_number, m.so_number AS reference_number,
'Trading Sales' AS transaction_type, 'Penjualan' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -522,13 +724,15 @@ SELECT
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category, ), '') AS product_sub_category,
w.name AS source_warehouse, w.name AS source_warehouse,
'RETAIL CUSTOMER' AS destination_warehouse, COALESCE(c.name, '') AS destination_warehouse,
'' AS destination, '' AS destination,
mp.qty AS quantity, mp.qty AS quantity,
u.id AS unit_id,
u.name AS unit, u.name AS unit,
m.notes AS notes m.notes AS notes
FROM marketing_products mp FROM marketing_products mp
JOIN marketings m ON m.id = mp.marketing_id JOIN marketings m ON m.id = mp.marketing_id
LEFT JOIN customers c ON c.id = m.customer_id
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products prod ON prod.id = pw.product_id JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id JOIN uoms u ON u.id = prod.uom_id
@@ -539,7 +743,7 @@ WHERE pw.project_flock_kandang_id IN ?
FROM flags f FROM flags f
WHERE f.flagable_id = pw.product_id WHERE f.flagable_id = pw.product_id
AND f.flagable_type = 'products' AND f.flagable_type = 'products'
AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET') AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK')
) )
` `
) )
@@ -597,6 +801,23 @@ var (
sapronakFlagsChickin = sapronakFlags(utils.FlagDOC, utils.FlagPullet) sapronakFlagsChickin = sapronakFlags(utils.FlagDOC, utils.FlagPullet)
) )
func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlias string) *gorm.DB {
subquery := r.DB().
Table("flags").
Select("DISTINCT ON (flagable_id) flagable_id, name").
Where("flagable_type = ?", entity.FlagableTypeProduct).
Where("name IN ?", sapronakFlagsAll).
Order(fmt.Sprintf(
"flagable_id, CASE WHEN name = '%s' THEN 1 WHEN name = '%s' THEN 2 WHEN name = '%s' THEN 3 WHEN name = '%s' THEN 4 ELSE 5 END",
utils.FlagDOC,
utils.FlagPullet,
utils.FlagPakan,
utils.FlagOVK,
))
return db.Joins("JOIN (?) f ON f.flagable_id = "+productAlias+".id", subquery)
}
func groupSapronakDetails(rows []SapronakDetailRow) map[uint][]SapronakDetailRow { func groupSapronakDetails(rows []SapronakDetailRow) map[uint][]SapronakDetailRow {
m := make(map[uint][]SapronakDetailRow) m := make(map[uint][]SapronakDetailRow)
for _, row := range rows { for _, row := range rows {
@@ -633,11 +854,12 @@ func (r *ClosingRepositoryImpl) usageQuery(
COALESCE(p.product_price, 0) AS default_price COALESCE(p.product_price, 0) AS default_price
`) `)
db = applyJoins(db, joins...) db = applyJoins(db, joins...)
return db. db = db.
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").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where(where, args...) Where(where, args...)
db = r.joinSapronakProductFlag(db, "p")
return db
} }
func (r *ClosingRepositoryImpl) fetchSapronakUsage( func (r *ClosingRepositoryImpl) fetchSapronakUsage(
@@ -667,11 +889,11 @@ 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")
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct)
db = applyJoins(db, joins...) db = applyJoins(db, joins...)
db = r.joinSapronakProductFlag(db, "p")
return db.Select(selectSQL).Where(where, args...) return db.Select(selectSQL).Where(where, args...)
} }
@@ -761,16 +983,85 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Con
) )
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil
}
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
p_resolve.id AS product_id,
p_resolve.name AS product_name,
f.name AS flag,
COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
ast.created_at,
pc.chick_in_date,
r.record_datetime
) AS date,
COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
CONCAT('ADJ-', ast.id),
CONCAT('CHICKIN-', pc.id),
CAST(r.id AS TEXT),
''
) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p_resolve.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()).
Joins("LEFT JOIN recordings r ON r.id = rs.recording_id").
Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll).
Where(`
(sa.usable_type = ? AND r.project_flock_kandangs_id = ?)
OR
(sa.usable_type = ? AND pc_used.project_flock_kandang_id = ?)
`,
fifo.UsableKeyRecordingStock.String(), projectFlockKandangID,
fifo.UsableKeyProjectChickin.String(), projectFlockKandangID,
)
query = r.joinSapronakProductFlag(query, "p_resolve").
Group(`
p_resolve.id, p_resolve.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime,
po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id,
pi.price, p_resolve.product_price
`)
return scanAndGroupDetails(query)
}
func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB {
return r.withCtx(ctx). db := r.withCtx(ctx).
Table("purchase_items AS pi"). Table("purchase_items AS pi").
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
Joins("JOIN products p ON p.id = pi.product_id"). Joins("JOIN products p ON p.id = pi.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). Joins("JOIN warehouses w ON w.id = pi.warehouse_id").
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL") Where("pi.received_date IS NOT NULL")
return r.joinSapronakProductFlag(db, "p")
} }
func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) {
@@ -837,14 +1128,14 @@ 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").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN warehouses w ON w.id = pw.warehouse_id") Joins("JOIN warehouses w ON w.id = pw.warehouse_id")
db = applyJoins(db, joins...) db = applyJoins(db, joins...)
db = r.joinSapronakProductFlag(db, "p")
if err := db. if err := db.
Where("sl.loggable_type = ?", logType). Where("sl.loggable_type = ?", logType).
@@ -887,12 +1178,75 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
} }
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false) poByWarehouse := r.DB().
Table("purchase_items pi").
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date").
Joins("JOIN purchases po ON po.id = pi.purchase_id").
Where("pi.received_date IS NOT NULL").
Order("pi.product_warehouse_id, pi.received_date ASC")
incomingQuery := r.withCtx(ctx).
Table("adjustment_stocks AS ast").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
ast.created_at AS date,
CONCAT('ADJ-', ast.id) AS reference,
COALESCE(ast.total_qty, 0) AS qty_in,
0 AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("COALESCE(ast.total_qty, 0) > 0")
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { return fmt.Sprintf("ADJ-%d", row.ID) })
return in, out, nil outgoingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date,
COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN adjustment_stocks ast_in ON ast_in.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Joins("LEFT JOIN (?) pfp_po ON pfp_po.product_warehouse_id = pfp.product_warehouse_id", poByWarehouse).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil {
return nil, nil, err
}
return incoming, outgoing, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
@@ -909,17 +1263,50 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
COALESCE(p.product_price, 0) AS price COALESCE(p.product_price, 0) AS price
`). `).
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = std.product_id"). Joins("JOIN products p ON p.id = std.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll) Where("f.name IN ?", sapronakFlagsAll)
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incoming, err := scanAndGroupDetails(incomingQuery) incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
incomingLayingQuery := r.withCtx(ctx).
Table("laying_transfer_targets AS ltt").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
lt.transfer_date::timestamp AS date,
COALESCE(lt.transfer_number, '') AS reference,
COALESCE(ltt.total_qty, 0) AS qty_in,
0 AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = lt.id").
Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lts.product_warehouse_id").
Joins("LEFT JOIN warehouses w_source ON w_source.id = pw_source.warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("w.kandang_id = ?", kandangID).
Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll)
incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p")
incomingLaying, err := scanAndGroupDetails(incomingLayingQuery)
if err != nil {
return nil, nil, err
}
for pid, rows := range incomingLaying {
incoming[pid] = append(incoming[pid], rows...)
}
outgoingQuery := r.withCtx(ctx). outgoingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
@@ -936,20 +1323,178 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = std.dest_product_warehouse_id").
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
Joins("JOIN products p ON p.id = std.product_id"). Joins("JOIN products p ON p.id = std.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoing, err := scanAndGroupDetails(outgoingQuery) outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
outgoingLayingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
lt.transfer_date::timestamp AS date,
COALESCE(lt.transfer_number, '') AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN laying_transfer_sources lts ON lts.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = lt.id").
Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = ltt.product_warehouse_id").
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll).
Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price")
outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p")
outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery)
if err != nil {
return nil, nil, err
}
for pid, rows := range outgoingLaying {
outgoing[pid] = append(outgoing[pid], rows...)
}
return incoming, outgoing, nil return incoming, outgoing, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(mdp.delivery_date, mdp.created_at) AS date,
COALESCE(m.so_number, '') AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(mdp.unit_price, mp.unit_price, 0) AS price
`).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()).
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
query = r.joinSapronakProductFlag(query, "p")
sales, err := scanAndGroupDetails(query)
if err != nil {
return nil, err
}
nonFifoQuery := r.withCtx(ctx).
Table("marketing_delivery_products AS mdp").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(mdp.delivery_date, mdp.created_at) AS date,
COALESCE(m.so_number, '') AS reference,
0 AS qty_in,
COALESCE(mdp.usage_qty, 0) AS qty_out,
COALESCE(mdp.unit_price, mp.unit_price, 0) AS price
`).
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ?",
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
).
Where("mdp.usage_qty > 0").
Where("sa.id IS NULL").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p")
nonFifoSales, err := scanAndGroupDetails(nonFifoQuery)
if err != nil {
return nil, err
}
for pid, rows := range nonFifoSales {
sales[pid] = append(sales[pid], rows...)
}
return sales, nil
}
func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil
}
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
ast.created_at
) AS date,
COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
CONCAT('ADJ-', ast.id),
''
) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group(`
pw.product_id, p.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at,
po.po_number, st.movement_number, lt.transfer_number, ast.id,
pi.price, p.product_price
`)
query = r.joinSapronakProductFlag(query, "p")
return scanAndGroupDetails(query)
}
func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) {
if len(productIDs) == 0 { if len(productIDs) == 0 {
return []entity.Product{}, nil return []entity.Product{}, nil
@@ -1,365 +0,0 @@
package repository
import (
"context"
"fmt"
"sort"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gorm.io/gorm"
)
// ClosingKeuanganRepository handles database operations for closing keuangan
type ClosingKeuanganRepository interface {
repository.BaseRepository[interface{}]
// All Product Usage
GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error)
// Depletion per kandang
GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
// Weight produced from uniformity per kandang
GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
// DB returns the underlying GORM DB instance
DB() *gorm.DB
}
type ClosingKeuanganRepositoryImpl struct {
*repository.BaseRepositoryImpl[interface{}]
}
func NewClosingKeuanganRepository(db *gorm.DB) ClosingKeuanganRepository {
return &ClosingKeuanganRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[interface{}](db),
}
}
// Result Rows
type ProductUsageRow struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
FlagNames string `gorm:"column:flag_names"`
TotalQty float64 `gorm:"column:total_qty"`
Price float64 `gorm:"column:price"`
TotalPengeluaran float64 `gorm:"column:total_pengeluaran"`
}
// GetAllProductUsageByProjectFlockKandangID gets all product usage for a project flock kandang
// Combines data from all usable types: recordings, chickins, marketing, transfers, adjustments
// flagFilters: optional filter to get only specific flags (e.g., ["PAKAN", "OVK"]), empty means get all
func (r *ClosingKeuanganRepositoryImpl) GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) {
if projectFlockKandangID == 0 {
return []ProductUsageRow{}, nil
}
type SubQueryResult struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
TotalQty float64 `gorm:"column:total_qty"`
Price float64 `gorm:"column:price"`
}
type AggregatedResult struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
TotalQty float64 `gorm:"column:total_qty"`
Price float64 `gorm:"column:price"`
PriceCount int `gorm:"-"` // For calculating average price
}
type FlagResult struct {
ProductID uint `gorm:"column:product_id"`
FlagNames string `gorm:"column:flag_names"`
}
var allResults []SubQueryResult
// Subquery 1: Recordings
var recordingsResults []SubQueryResult
err := r.DB().WithContext(ctx).
Table("recordings r").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(CASE "+
"WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(sa.qty, 0) "+
"WHEN sa.stockable_type = 'STOCK_TRANSFER_IN' THEN COALESCE(std.usage_qty, 0) "+
"WHEN sa.stockable_type = 'TRANSFERTOLAYING_IN' THEN COALESCE(ltt.total_used, 0) "+
"WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN COALESCE(adjs.total_used, 0) "+
"WHEN sa.stockable_type = 'PROJECT_FLOCK_POPULATION' THEN COALESCE(pfp.total_used_qty, 0) "+
"ELSE 0 END), 0) as total_qty, "+
"COALESCE(AVG(CASE WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(pi.price, 0) END), 0) as price").
Joins("JOIN recording_stocks rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN stock_allocations sa ON sa.usable_type = 'RECORDING_STOCK' AND sa.usable_id = rs.id AND sa.status = 'ACTIVE'").
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = 'PURCHASE_ITEMS'").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = 'STOCK_TRANSFER_IN'").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = 'TRANSFERTOLAYING_IN'").
Joins("LEFT JOIN adjustment_stocks adjs ON adjs.id = sa.stockable_id AND sa.stockable_type = 'ADJUSTMENT_IN'").
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = 'PROJECT_FLOCK_POPULATION'").
Where("r.project_flock_kandangs_id = ?", projectFlockKandangID).
Where("r.deleted_at IS NULL").
Group("pw.product_id, p.name").
Scan(&recordingsResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get recordings product usage: %w", err)
}
fmt.Printf("[REPO] Recordings query: %d results for projectFlockKandangID=%d\n", len(recordingsResults), projectFlockKandangID)
allResults = append(allResults, recordingsResults...)
// Subquery 2: Chickins
var chickinsResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("project_chickins pc").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(pc.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN product_warehouses pw ON pw.id = pc.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
Where("pc.usage_qty > 0").
Group("pw.product_id, p.name").
Scan(&chickinsResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get chickins product usage: %w", err)
}
fmt.Printf("[REPO] Chickins query: %d results for projectFlockKandangID=%d\n", len(chickinsResults), projectFlockKandangID)
allResults = append(allResults, chickinsResults...)
// Subquery 3: Marketing Delivery
var marketingResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("marketing_delivery_products mdp").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(mdp.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Group("pw.product_id, p.name").
Scan(&marketingResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get marketing product usage: %w", err)
}
fmt.Printf("[REPO] Marketing query: %d results for projectFlockKandangID=%d\n", len(marketingResults), projectFlockKandangID)
allResults = append(allResults, marketingResults...)
// Subquery 4: Laying Transfer Sources
var layingTransferResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("laying_transfer_sources lts").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(lts.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id").
Joins("JOIN product_warehouses pw ON pw.id = lts.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Group("pw.product_id, p.name").
Scan(&layingTransferResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get laying transfer product usage: %w", err)
}
fmt.Printf("[REPO] Laying Transfer query: %d results for projectFlockKandangID=%d\n", len(layingTransferResults), projectFlockKandangID)
allResults = append(allResults, layingTransferResults...)
// Subquery 5: Stock Transfer Details
var stockTransferResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("stock_transfer_details std").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(std.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN product_warehouses pw ON pw.id = std.source_product_warehouse_id").
Joins("JOIN products p ON p.id = std.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Group("pw.product_id, p.name").
Scan(&stockTransferResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get stock transfer product usage: %w", err)
}
fmt.Printf("[REPO] Stock Transfer query: %d results for projectFlockKandangID=%d\n", len(stockTransferResults), projectFlockKandangID)
allResults = append(allResults, stockTransferResults...)
// Subquery 6: Adjustment Stocks
var adjustmentResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("adjustment_stocks ads").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(ads.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN product_warehouses pw ON pw.id = ads.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("ads.usage_qty > 0").
Group("pw.product_id, p.name").
Scan(&adjustmentResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get adjustment product usage: %w", err)
}
fmt.Printf("[REPO] Adjustment query: %d results for projectFlockKandangID=%d\n", len(adjustmentResults), projectFlockKandangID)
allResults = append(allResults, adjustmentResults...)
fmt.Printf("[REPO] Total raw results before aggregation: %d items\n", len(allResults))
// Aggregate results by product_id
aggregatedMap := make(map[uint]*AggregatedResult)
for _, result := range allResults {
key := result.ProductID
if existing, exists := aggregatedMap[key]; exists {
existing.TotalQty += result.TotalQty
existing.Price += result.Price
existing.PriceCount++
} else {
aggregatedMap[key] = &AggregatedResult{
ProductID: result.ProductID,
ProductName: result.ProductName,
TotalQty: result.TotalQty,
Price: result.Price,
PriceCount: 1,
}
}
}
fmt.Printf("[REPO] Aggregated to %d unique products\n", len(aggregatedMap))
// Get flags for all products
productIDs := make([]uint, 0, len(aggregatedMap))
for id := range aggregatedMap {
productIDs = append(productIDs, id)
}
var flagResults []FlagResult
if len(productIDs) > 0 {
err = r.DB().WithContext(ctx).
Table("products p").
Select("p.id as product_id, STRING_AGG(DISTINCT f.name, ', ') as flag_names").
Joins("LEFT JOIN flags f ON f.flagable_type = 'products' AND f.flagable_id = p.id").
Where("p.id IN ?", productIDs).
Group("p.id").
Scan(&flagResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get product flags: %w", err)
}
}
fmt.Printf("[REPO] Fetched flags for %d products\n", len(flagResults))
// Build flag map
flagMap := make(map[uint]string)
for _, flag := range flagResults {
flagMap[flag.ProductID] = flag.FlagNames
}
// Combine results and calculate average price
results := make([]ProductUsageRow, 0, len(aggregatedMap))
for _, agg := range aggregatedMap {
avgPrice := float64(0)
if agg.PriceCount > 0 {
avgPrice = agg.Price / float64(agg.PriceCount)
}
flagNames := flagMap[agg.ProductID]
// Apply flag filters if provided
if len(flagFilters) > 0 {
// Check if any of the flagFilters exist in flagNames
matched := false
for _, filter := range flagFilters {
if containsIgnoreCase(flagNames, filter) {
matched = true
break
}
}
if !matched {
continue // Skip this product if no flag matches
}
}
results = append(results, ProductUsageRow{
ProductID: agg.ProductID,
ProductName: agg.ProductName,
FlagNames: flagNames,
TotalQty: agg.TotalQty,
Price: avgPrice,
TotalPengeluaran: agg.TotalQty * avgPrice,
})
}
fmt.Printf("[REPO] After filtering with flagFilters=%v: %d results\n", flagFilters, len(results))
for i, r := range results {
fmt.Printf("[REPO] Result[%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f\n",
i, r.ProductID, r.ProductName, r.FlagNames, r.TotalQty, r.Price, r.TotalPengeluaran)
}
// Sort by product name
sort.Slice(results, func(i, j int) bool {
return results[i].ProductName < results[j].ProductName
})
fmt.Printf("[REPO] Final sorted results: %d items\n", len(results))
return results, nil
}
// GetTotalDepletionByProjectFlockKandangID gets total depletion for a specific kandang
func (r *ClosingKeuanganRepositoryImpl) GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) {
var result float64
err := r.DB().WithContext(ctx).
Table("recording_depletions").
Select("COALESCE(SUM(recording_depletions.qty), 0)").
Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id").
Where("project_flock_kandangs.id = ?", projectFlockKandangID).
Scan(&result).Error
return result, err
}
// GetTotalWeightProducedFromUniformityByProjectFlockKandangID calculates total weight produced from uniformity data for a specific kandang
// Formula: (mean_up / 1.10) * chick_qty_of_weight / 1000
func (r *ClosingKeuanganRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) {
if projectFlockKandangID == 0 {
return 0, nil
}
var uniformity struct {
MeanUp float64
ChickQtyOfWeight float64
}
err := r.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity").
Select("mean_up, chick_qty_of_weight").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Order("id DESC").
Limit(1).
Scan(&uniformity).Error
if err != nil {
return 0, err
}
// Calculate weight: (mean_up / 1.10) * chick_qty_of_weight / 1000
totalWeight := (uniformity.MeanUp / 1.10) * uniformity.ChickQtyOfWeight / 1000
return totalWeight, nil
}
// containsIgnoreCase checks if a string contains a substring (case-insensitive)
func containsIgnoreCase(str, substr string) bool {
return strings.Contains(strings.ToUpper(str), strings.ToUpper(substr))
}
+1
View File
@@ -30,6 +30,7 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService
route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang) route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang)
route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByProject) route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByProject)
route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak) route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak)
route.Get("/:projectFlockId/sapronak/summary", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronakSummary)
route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPP) route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPP)
route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang) route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang)
route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi)
@@ -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"
@@ -40,6 +41,7 @@ type ClosingService interface {
GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error)
GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error)
GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error)
GetClosingSapronakSummary(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error)
GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error)
} }
@@ -97,10 +99,43 @@ 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 := ""
if params.ProjectStatus != nil {
switch *params.ProjectStatus {
case 1:
statusFilter = "Pengajuan"
case 2:
statusFilter = "Aktif"
}
}
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 {
db = db.Where("location_id = ?", *params.LocationID)
}
if statusFilter != "" {
latestApprovalSubQuery := s.Repository.DB().
WithContext(c.Context()).
Table("approvals").
Select("DISTINCT ON (approvable_id) approvable_id, step_name, id").
Where("approvable_type = ?", utils.ApprovalWorkflowProjectFlock.String()).
Order("approvable_id, id DESC")
db = db.Joins("JOIN (?) AS latest_approval ON latest_approval.approvable_id = project_flocks.id", latestApprovalSubQuery).
Where("LOWER(latest_approval.step_name) = LOWER(?)", statusFilter)
}
if params.Search != "" { if params.Search != "" {
return db.Where("flock_name ILIKE ?", "%"+params.Search+"%") return db.Where("flock_name ILIKE ?", "%"+params.Search+"%")
} }
@@ -127,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")
@@ -138,6 +177,13 @@ 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 {
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
}
realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID) realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID)
if err != nil { if err != nil {
@@ -151,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 {
@@ -236,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")
@@ -298,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 {
@@ -321,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")
@@ -353,6 +391,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
ProjectFlockKandangIDs: projectFlockKandangIDs, ProjectFlockKandangIDs: projectFlockKandangIDs,
Limit: params.Limit, Limit: params.Limit,
Offset: offset, Offset: offset,
Search: params.Search,
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -387,13 +426,84 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
return items, totalResults, nil return items, totalResults, nil
} }
func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if params == nil {
params = &validation.ClosingSapronakQuery{}
}
if err := s.Validate.Struct(params); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if params.Type != validation.SapronakTypeIncoming && params.Type != validation.SapronakTypeOutgoing {
return nil, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
}
if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan")
}
s.Log.Errorf("Failed get project flock %d for sapronak closing summary: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID, params.KandangID)
if err != nil {
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")
}
var projectFlockKandangIDs []uint
if params.KandangID != nil && *params.KandangID > 0 {
projectFlockKandangIDs = []uint{*params.KandangID}
} else if params.Type == validation.SapronakTypeOutgoing {
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang")
}
}
rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{
Type: params.Type,
WarehouseIDs: warehouseIDs,
ProjectFlockKandangIDs: projectFlockKandangIDs,
Search: params.Search,
})
if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sapronak summary data")
}
items := make([]dto.ClosingSapronakSummaryItemDTO, 0, len(rows))
for _, row := range rows {
items = append(items, dto.ClosingSapronakSummaryItemDTO{
Category: row.Category,
TotalQty: row.TotalQty,
Uom: dto.UomSummaryDTO{
ID: row.UomID,
Name: row.UomName,
},
})
}
return items, nil
}
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
} }
@@ -445,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
} }
@@ -488,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
@@ -576,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)
@@ -651,6 +773,10 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine production week") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine production week")
} }
if !isGrowing && currentWeek != 0 {
currentWeek = currentWeek + 17
}
targetAverages, err := s.RecordingRepo.GetAverageTargetMetricsByProjectFlockKandangID(c.Context(), projectFlockKandangIDs[0], !isGrowing) targetAverages, err := s.RecordingRepo.GetAverageTargetMetricsByProjectFlockKandangID(c.Context(), projectFlockKandangIDs[0], !isGrowing)
if err != nil { if err != nil {
s.Log.Errorf("Failed to calculate target metrics for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to calculate target metrics for project flock %d: %+v", projectFlockID, err)
@@ -710,15 +836,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
finalPopulation := population - claimCulling finalPopulation := population - claimCulling
var standards []entity.FcrStandard age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID, kandangID)
if project.FcrId > 0 {
standards, err = s.Repository.GetFcrStandardsByFcrID(c.Context(), project.FcrId)
if err != nil {
s.Log.Errorf("Failed to fetch FCR standards for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data")
}
}
age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID)
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")
@@ -738,7 +856,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)
@@ -767,7 +885,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
chickenDepletion = 0 chickenDepletion = 0
} }
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age, standards) chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age)
if fcrActFromRecording != nil { if fcrActFromRecording != nil {
chickenPerformance.FcrAct = *fcrActFromRecording chickenPerformance.FcrAct = *fcrActFromRecording
} }
@@ -817,7 +935,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
eggDepletion = 0 eggDepletion = 0
} }
eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age, standards) eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age)
if fcrActFromRecording != nil { if fcrActFromRecording != nil {
eggPerf.FcrAct = *fcrActFromRecording eggPerf.FcrAct = *fcrActFromRecording
} }
@@ -860,25 +978,25 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
if !isGrowing { if !isGrowing {
if targetAverages.HenDayCount > 0 { if targetAverages.HenDayCount > 0 {
henDayAct := targetAverages.HenDayAvg henDayAct := targetAverages.HenDayAvg
performance.HenDayAct = &henDayAct performance.HenDayAct = henDayAct
} }
if targetAverages.HenHouseCount > 0 { if targetAverages.HenHouseCount > 0 {
henHouseAct := targetAverages.HenHouseAvg henHouseAct := targetAverages.HenHouseAvg
performance.HenHouseAct = &henHouseAct performance.HenHouseAct = henHouseAct
} }
if targetAverages.EggWeightCount > 0 { if targetAverages.EggWeightCount > 0 {
eggWeight := targetAverages.EggWeightAvg eggWeight := targetAverages.EggWeightAvg
performance.EggWeight = &eggWeight performance.EggWeight = eggWeight
} }
if targetAverages.EggMassCount > 0 { if targetAverages.EggMassCount > 0 {
eggMass := targetAverages.EggMassAvg eggMass := targetAverages.EggMassAvg
performance.EggMass = &eggMass performance.EggMass = eggMass
} }
} }
performance.DeffFcr = performance.FcrStd - performance.FcrAct
if productionStandardDetail != nil { if productionStandardDetail != nil {
if productionStandardDetail.StandardFCR != nil { if productionStandardDetail.StandardFCR != nil {
performance.FcrStd = *productionStandardDetail.StandardFCR performance.FcrStd = *productionStandardDetail.StandardFCR
performance.DeffFcr = performance.FcrStd - performance.FcrAct
} }
if !isGrowing { if !isGrowing {
if productionStandardDetail.TargetHenDayProduction != nil { if productionStandardDetail.TargetHenDayProduction != nil {
@@ -905,38 +1023,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) {
@@ -979,8 +1083,8 @@ func (s closingService) determineProductionWeek(ctx context.Context, projectFloc
return week, nil return week, nil
} }
func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO { func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64) dto.ClosingPerformanceDTO {
mortalityStd, fcrStd := closestFcrValues(standards, averageWeight) mortalityStd, fcrStd := 0.0, 0.0
fcrAct := 0.0 fcrAct := 0.0
if totalWeight > 0 { if totalWeight > 0 {
@@ -1012,22 +1116,3 @@ func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopul
AwgAct: awg, AwgAct: awg,
} }
} }
func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (float64, float64) {
if len(standards) == 0 || averageWeight <= 0 {
return 0, 0
}
closest := standards[0]
minDiff := math.Abs(closest.Weight - averageWeight)
for _, std := range standards[1:] {
diff := math.Abs(std.Weight - averageWeight)
if diff < minDiff {
minDiff = diff
closest = std
}
}
return closest.Mortality, closest.FcrNumber
}
@@ -2,20 +2,19 @@ package service
import ( import (
"errors" "errors"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
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"
"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"
expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -25,9 +24,29 @@ type ClosingKeuanganService interface {
GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error)
} }
// CostData holds all cost-related information
type CostData struct {
FeedCost float64
OvkCost float64
ChickenCost float64
ExpeditionCost float64
BudgetOperational float64
RealizationOperational float64
}
// ProductionData holds all production and sales related information
type ProductionData struct {
TotalPopulationIn float64
TotalDepletion float64
TotalWeightProduced float64
TotalEggWeightKg float64
TotalWeightSold float64
TotalBirdSold float64
TotalSalesAmount float64
}
type closingKeuanganService struct { type closingKeuanganService struct {
Log *logrus.Logger Log *logrus.Logger
ClosingKeuanganRepo repository.ClosingKeuanganRepository
ProjectFlockRepo projectflockRepository.ProjectflockRepository ProjectFlockRepo projectflockRepository.ProjectflockRepository
ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository
MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository
@@ -35,10 +54,11 @@ type closingKeuanganService struct {
ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository
ChickinRepo chickinRepository.ProjectChickinRepository ChickinRepo chickinRepository.ProjectChickinRepository
RecordingRepo recordingRepository.RecordingRepository RecordingRepo recordingRepository.RecordingRepository
HppSvc commonSvc.HppService
HppRepo commonRepo.HppCostRepository
} }
func NewClosingKeuanganService( func NewClosingKeuanganService(
closingKeuanganRepo repository.ClosingKeuanganRepository,
projectFlockRepo projectflockRepository.ProjectflockRepository, projectFlockRepo projectflockRepository.ProjectflockRepository,
projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository, projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository,
marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository,
@@ -46,10 +66,11 @@ func NewClosingKeuanganService(
projectBudgetRepo projectflockRepository.ProjectBudgetRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository,
chickinRepo chickinRepository.ProjectChickinRepository, chickinRepo chickinRepository.ProjectChickinRepository,
recordingRepo recordingRepository.RecordingRepository, recordingRepo recordingRepository.RecordingRepository,
hppSvc commonSvc.HppService,
hppRepo commonRepo.HppCostRepository,
) ClosingKeuanganService { ) ClosingKeuanganService {
return &closingKeuanganService{ return &closingKeuanganService{
Log: utils.Log, Log: utils.Log,
ClosingKeuanganRepo: closingKeuanganRepo,
ProjectFlockRepo: projectFlockRepo, ProjectFlockRepo: projectFlockRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
@@ -57,6 +78,8 @@ func NewClosingKeuanganService(
ProjectBudgetRepo: projectBudgetRepo, ProjectBudgetRepo: projectBudgetRepo,
ChickinRepo: chickinRepo, ChickinRepo: chickinRepo,
RecordingRepo: recordingRepo, RecordingRepo: recordingRepo,
HppSvc: hppSvc,
HppRepo: hppRepo,
} }
} }
@@ -73,30 +96,12 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
} }
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
// Preload Nonstock.Flags manually
var budgetIDs []uint
for _, b := range budgets {
budgetIDs = append(budgetIDs, b.Id)
}
if len(budgetIDs) > 0 {
err = s.ProjectBudgetRepo.DB().WithContext(c.Context()).
Preload("Nonstock.Flags").
Where("id IN ?", budgetIDs).
Find(&budgets).Error
}
// Get all kandang for this project flock
kandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs")
} }
return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID) return s.calculateClosingKeuangan(c, projectFlock, projectFlockKandangs)
} }
func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) { func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) {
@@ -107,12 +112,11 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec
return nil, err return nil, err
} }
// Validate and fetch project flock kandang projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID)
kandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found")
} }
if kandang.ProjectFlockId != projectFlockID { if projectFlockKandang.ProjectFlockId != projectFlockID {
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang does not belong to this project flock") return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang does not belong to this project flock")
} }
@@ -121,417 +125,253 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
} }
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) projectFlockKandangs := []entity.ProjectFlockKandang{*projectFlockKandang}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
// Preload Nonstock.Flags manually return s.calculateClosingKeuangan(c, projectFlock, projectFlockKandangs)
var budgetIDs []uint
for _, b := range budgets {
budgetIDs = append(budgetIDs, b.Id)
}
if len(budgetIDs) > 0 {
err = s.ProjectBudgetRepo.DB().WithContext(c.Context()).
Preload("Nonstock.Flags").
Where("id IN ?", budgetIDs).
Find(&budgets).Error
}
kandangs := []entity.ProjectFlockKandang{*kandang}
return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID)
} }
func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, budgets []entity.ProjectBudget, kandangs []entity.ProjectFlockKandang, scopeID uint) (*dto.ClosingKeuanganData, error) { func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang) (*dto.ClosingKeuanganData, error) {
// Define flag filters using constants
pakanFilters := []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher)}
ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}
ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)}
allFilters := append(pakanFilters, ovkFilters...)
allFilters = append(allFilters, ayamFilters...)
var allProductUsageRows []repository.ProductUsageRow var projectFlockKandangIDs []uint
for _, projectFlockKandang := range projectFlockKandangs {
// Get ALL product usage projectFlockKandangIDs = append(projectFlockKandangIDs, projectFlockKandang.Id)
for _, kandang := range kandangs {
rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters)
if err == nil {
allProductUsageRows = append(allProductUsageRows, rows...)
}
} }
// Classify into categories based on flag priority isPerKandang := len(projectFlockKandangs) == 1
var pakanProductUsageRows []repository.ProductUsageRow
var ovkProductUsageRows []repository.ProductUsageRow
var ayamProductUsageRows []repository.ProductUsageRow
for _, row := range allProductUsageRows {
// Parse flag names from comma-separated string
flagNames := strings.Split(row.FlagNames, ",")
hasPakanFlag := false
hasOvkFlag := false
hasAyamFlag := false
for _, flag := range flagNames {
flag = strings.TrimSpace(flag)
if containsItem(pakanFilters, flag) {
hasPakanFlag = true
}
if containsItem(ovkFilters, flag) {
hasOvkFlag = true
}
if containsItem(ayamFilters, flag) {
hasAyamFlag = true
}
}
// Priority: PAKAN > OVK > AYAM
if hasPakanFlag {
pakanProductUsageRows = append(pakanProductUsageRows, row)
} else if hasOvkFlag {
ovkProductUsageRows = append(ovkProductUsageRows, row)
} else if hasAyamFlag {
ayamProductUsageRows = append(ayamProductUsageRows, row)
} else {
continue
}
}
// Calculate total price for each category
var totalPakanPrice, totalOvkPrice, totalAyamPrice float64
for _, row := range pakanProductUsageRows {
totalPakanPrice += row.TotalPengeluaran
}
for _, row := range ovkProductUsageRows {
totalOvkPrice += row.TotalPengeluaran
}
for _, row := range ayamProductUsageRows {
totalAyamPrice += row.TotalPengeluaran
}
// Determine if this is per-kandang or per-project-flock scope
isPerKandang := len(kandangs) == 1
var projectFlockKandangID *uint var projectFlockKandangID *uint
if isPerKandang { if isPerKandang {
kandangID := kandangs[0].Id kandangID := projectFlockKandangs[0].Id
projectFlockKandangID = &kandangID projectFlockKandangID = &kandangID
} }
costs, err := s.calculateCosts(c, projectFlock, projectFlockKandangs, projectFlockKandangIDs, projectFlockKandangID)
if err != nil {
return nil, err
}
productionData, err := s.calculateProductionData(c, projectFlock, projectFlockKandangIDs, projectFlockKandangID)
if err != nil {
return nil, err
}
hppSection := s.buildHPPSection(c, projectFlock, projectFlockKandangs, costs, productionData)
profitLossSection := s.buildProfitLossSection(projectFlock, costs, productionData)
data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
return &data, nil
}
func (s closingKeuanganService) calculateCosts(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, projectFlockKandangIDs []uint, projectFlockKandangID *uint) (*CostData, error) {
costs := &CostData{}
var err error var err error
// Fetch realizations costs.FeedCost, err = s.HppRepo.GetFeedUsageCost(c.Context(), projectFlockKandangIDs, nil)
var realizations []entity.ExpenseRealization
if isPerKandang && projectFlockKandangID != nil {
realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID)
} else {
realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, nil)
}
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") costs.FeedCost = 0
} }
deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlock.Id, func(db *gorm.DB) *gorm.DB { costs.OvkCost, err = s.HppRepo.GetOvkUsageCost(c.Context(), projectFlockKandangIDs, nil)
db = db.Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product")
return db
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products")
}
// Filter by kandang if scope is per-kandang (manual filtering after fetch)
if isPerKandang && projectFlockKandangID != nil {
filteredProducts := make([]entity.MarketingDeliveryProduct, 0)
for _, dp := range deliveryProducts {
pfKandangID := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandangId
if pfKandangID != nil && *pfKandangID == *projectFlockKandangID {
filteredProducts = append(filteredProducts, dp)
}
}
deliveryProducts = filteredProducts
}
// Fetch chickins
var chickins []entity.ProjectChickin
if isPerKandang && projectFlockKandangID != nil {
chickins, err = s.ChickinRepo.GetByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
chickins, err = s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") costs.OvkCost = 0
} }
// Get total depletion
var totalDepletion float64
if isPerKandang && projectFlockKandangID != nil {
totalDepletion, err = s.ClosingKeuanganRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
totalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
totalDepletion = 0
}
totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlock.Id)
if err != nil {
}
// Try to get actual weight from uniformity data
var totalWeightFromUniformity float64
if isPerKandang && projectFlockKandangID != nil {
totalWeightFromUniformity, err = s.ClosingKeuanganRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
totalWeightFromUniformity, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
} else if totalWeightFromUniformity > 0 {
totalWeightProduced = totalWeightFromUniformity
}
// Fetch egg data only for Laying category
var totalEggWeightKg float64
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
// TODO: Replace with actual method to get egg weight from RecordingRepo for _, projectFlockKandang := range projectFlockKandangs {
// totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlock.Id) depresiasiCost, err := s.HppSvc.GetDepresiasiTransfer(projectFlockKandang.Id, nil)
// For now, set to 0 as placeholder if err == nil {
totalEggWeightKg = 0 costs.ChickenCost += depresiasiCost
}
pulletCost, err := s.HppRepo.GetPulletCost(c.Context(), projectFlockKandang.Id)
if err == nil {
costs.ChickenCost += pulletCost
}
}
} else { } else {
totalEggWeightKg = 0 for _, projectFlockKandang := range projectFlockKandangs {
pulletCost, err := s.HppRepo.GetPulletCost(c.Context(), projectFlockKandang.Id)
if err == nil {
costs.ChickenCost += pulletCost
}
}
} }
// Build new DTO structure costs.ExpeditionCost, err = s.HppRepo.GetExpedisionCost(c.Context(), projectFlockKandangIDs)
if err != nil {
// Calculate totals costs.ExpeditionCost = 0
var totalPopulation float64
for _, chickin := range chickins {
totalPopulation += chickin.UsageQty
} }
// Calculate actual population (total population - depletion) if budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlock.Id); err == nil {
actualPopulation := totalPopulation - totalDepletion totalBudget := 0.0
// Calculate budget totals by category
calculateBudgetByFlag := func(flags []string) float64 {
var total float64
for _, budget := range budgets { for _, budget := range budgets {
if budget.Nonstock != nil { totalBudget += budget.Price * budget.Qty
for _, nonstockFlag := range budget.Nonstock.Flags { }
flagName := strings.ToUpper(nonstockFlag.Name) if projectFlockKandangID != nil {
for _, targetFlag := range flags { allKandangs, errKandang := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlock.Id)
if flagName == strings.ToUpper(targetFlag) { if errKandang == nil && len(allKandangs) > 0 {
total += budget.Price * budget.Qty costs.BudgetOperational = totalBudget / float64(len(allKandangs))
break }
} } else {
} costs.BudgetOperational = totalBudget
} }
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch budgets for project_flock_id=%d: %+v", projectFlock.Id, err)
}
if realizations, err := s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID); err == nil {
for _, realization := range realizations {
amount := realization.Price * realization.Qty
isEkspedisi := realization.ExpenseNonstock != nil &&
realization.ExpenseNonstock.Nonstock != nil &&
containsFlag(realization.ExpenseNonstock.Nonstock.Flags, "EKSPEDISI")
if !isEkspedisi {
costs.RealizationOperational += amount
} }
} }
return total } else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch realizations for project_flock_id=%d: %+v", projectFlock.Id, err)
} }
// Budget per category return costs, nil
budgetPakan := calculateBudgetByFlag([]string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}) }
budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA"})
budgetAyam := calculateBudgetByFlag([]string{"DOC", "PULLET", "LAYER"})
budgetEkspedisi := calculateBudgetByFlag([]string{"EKSPEDISI"})
// Operational budget = total budget - pakan - ovk - ayam - ekspedisi func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangIDs []uint, projectFlockKandangID *uint) (*ProductionData, error) {
totalBudgetAmount := 0.0 data := &ProductionData{}
for _, budget := range budgets { var err error
totalBudgetAmount += budget.Price * budget.Qty
data.TotalPopulationIn, err = s.HppRepo.GetTotalPopulation(c.Context(), projectFlockKandangIDs)
if err != nil {
data.TotalPopulationIn = 0
} }
budgetOperational := totalBudgetAmount - budgetPakan - budgetOvk - budgetAyam - budgetEkspedisi
if projectFlockKandangID != nil {
data.TotalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
data.TotalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
data.TotalDepletion = 0
}
// Calculate realization totals if projectFlockKandangID != nil {
var totalRealizationAmount float64 data.TotalWeightProduced, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
var totalEkspedisiRealization float64 } else {
for _, realization := range realizations { data.TotalWeightProduced, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id)
amount := realization.Price * realization.Qty }
totalRealizationAmount += amount if err != nil {
data.TotalWeightProduced = 0
}
// Check if this is ekspedisi (need to check nonstock flags) if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
if realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Nonstock != nil { _, data.TotalEggWeightKg, err = s.HppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(c.Context(), projectFlockKandangIDs, nil)
for _, flag := range realization.ExpenseNonstock.Nonstock.Flags { if err != nil {
if flag.Name == "EKSPEDISI" { data.TotalEggWeightKg = 0
totalEkspedisiRealization += amount
break
}
}
} }
} }
totalOperationalRealization := totalRealizationAmount - totalEkspedisiRealization var deliveryProducts []entity.MarketingDeliveryProduct
if projectFlockKandangID != nil {
deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, projectFlockKandangID, projectFlock.Category)
} else {
deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, nil, projectFlock.Category)
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data penjualan")
}
// Filter delivery products based on category
var filteredDeliveryProducts []entity.MarketingDeliveryProduct
for _, delivery := range deliveryProducts { for _, delivery := range deliveryProducts {
// Get product from delivery
if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 { if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 {
continue continue
} }
data.TotalWeightSold += delivery.TotalWeight
product := delivery.MarketingProduct.ProductWarehouse.Product data.TotalBirdSold += delivery.UsageQty
isEggProduct := false data.TotalSalesAmount += delivery.TotalPrice
isChickenProduct := false
// Check product flags
for _, flag := range product.Flags {
flagName := strings.ToUpper(flag.Name)
// Egg product flags
if flagName == "TELUR" || flagName == "TELURUTUH" || flagName == "TELURPECAH" ||
flagName == "TELURPUTIH" || flagName == "TELURRETAK" {
isEggProduct = true
}
// Chicken product flags
if flagName == "AYAMAFKIR" || flagName == "AYAMCULLING" || flagName == "AYAMMATI" {
isChickenProduct = true
}
}
// Filter based on project flock category
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
// Laying: only egg products
if isEggProduct {
filteredDeliveryProducts = append(filteredDeliveryProducts, delivery)
}
} else {
// Growing/Contract Growing: only chicken products
if isChickenProduct || (!isEggProduct && !isChickenProduct) {
// Include if chicken product or if no specific flags (default to chicken)
filteredDeliveryProducts = append(filteredDeliveryProducts, delivery)
}
}
} }
return data, nil
}
// Calculate total weight sold and sales amount from filtered products func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection {
var totalWeightSold float64
var totalSalesAmount float64 actualPopulation := production.TotalPopulationIn - production.TotalDepletion
for _, delivery := range filteredDeliveryProducts { if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok {
totalWeightSold += delivery.TotalWeight actualPopulation = lastPopulation
totalSalesAmount += delivery.TotalPrice }
totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg
weightForCalculation := totalWeightProduced
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
weightForCalculation = totalEggWeightKg
} }
// Calculate metrics - always use kg ayam for rp_per_kg
calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if actualPopulation > 0 { if actualPopulation > 0 {
rpPerBird = amount / actualPopulation // Use actual population rpPerBird = amount / actualPopulation
} }
if totalWeightProduced > 0 { if weightForCalculation > 0 {
rpPerKg = amount / totalWeightProduced rpPerKg = amount / weightForCalculation
} }
return return
} }
// Calculate metrics for profit loss (use total population and total weight produced) createHPPItem := func(id uint, category, code, label string, budgetAmount, realizationAmount float64) dto.HPPItem {
calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { budgetRpPerBird, budgetRpPerKg := calculateMetrics(budgetAmount)
if totalPopulation > 0 { realizationRpPerBird, realizationRpPerKg := calculateMetrics(realizationAmount)
rpPerBird = amount / totalPopulation return dto.ToHPPItem(
} id,
if totalWeightProduced > 0 { category,
rpPerKg = amount / totalWeightProduced code,
} label,
return dto.ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount),
dto.ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount),
)
} }
// Build HPP Items using constants
hppItems := []dto.HPPItem{} hppItems := []dto.HPPItem{}
// PAKAN item hppItems = append(hppItems, createHPPItem(1, "purchase", string(dto.HPPCodePakan), "Pembelian Pakan", costs.FeedCost, costs.FeedCost))
pakanBudgetRpPerBird, pakanBudgetRpPerKg := calculateMetrics(budgetPakan) hppItems = append(hppItems, createHPPItem(2, "purchase", string(dto.HPPCodeOVK), "Pembelian OVK", costs.OvkCost, costs.OvkCost))
pakanRealizationRpPerBird, pakanRealizationRpPerKg := calculateMetrics(totalPakanPrice)
hppItems = append(hppItems, dto.ToHPPItem(
1,
"purchase",
string(dto.HPPCodePakan),
"Pembelian Pakan",
dto.ToFinancialMetrics(pakanBudgetRpPerBird, pakanBudgetRpPerKg, budgetPakan),
dto.ToFinancialMetrics(pakanRealizationRpPerBird, pakanRealizationRpPerKg, totalPakanPrice),
))
// OVK item
ovkBudgetRpPerBird, ovkBudgetRpPerKg := calculateMetrics(budgetOvk)
ovkRealizationRpPerBird, ovkRealizationRpPerKg := calculateMetrics(totalOvkPrice)
hppItems = append(hppItems, dto.ToHPPItem(
2,
"purchase",
string(dto.HPPCodeOVK),
"Pembelian OVK",
dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk),
dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice),
))
// DOC/DEPRESIASI item
docCode := string(dto.HPPCodeDOC) docCode := string(dto.HPPCodeDOC)
docLabel := "Pembelian DOC" docLabel := "Pembelian DOC"
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
docCode = string(dto.HPPCodeDepresiasi) docCode = string(dto.HPPCodeDepresiasi)
docLabel = "Depresiasi" docLabel = "Depresiasi"
} }
docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam) hppItems = append(hppItems, createHPPItem(3, "purchase", docCode, docLabel, costs.ChickenCost, costs.ChickenCost))
docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice) hppItems = append(hppItems, createHPPItem(4, "overhead", string(dto.HPPCodeOverhead), "Pengeluaran Overhead", costs.BudgetOperational, costs.RealizationOperational))
hppItems = append(hppItems, dto.ToHPPItem( hppItems = append(hppItems, createHPPItem(5, "overhead", string(dto.HPPCodeEkspedisi), "Beban Ekspedisi", costs.ExpeditionCost, costs.ExpeditionCost))
3,
"purchase",
docCode,
docLabel,
dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam),
dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice),
))
// OVERHEAD item totalBudgetHpp := costs.FeedCost + costs.OvkCost + costs.ChickenCost + costs.BudgetOperational + costs.ExpeditionCost
overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational) totalRealizationHpp := costs.FeedCost + costs.OvkCost + costs.ChickenCost + costs.RealizationOperational + costs.ExpeditionCost
overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization)
hppItems = append(hppItems, dto.ToHPPItem(
4,
"overhead",
string(dto.HPPCodeOverhead),
"Pengeluaran Overhead",
dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational),
dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization),
))
// EKSPEDISI item
ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi)
ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization)
hppItems = append(hppItems, dto.ToHPPItem(
5,
"overhead",
string(dto.HPPCodeEkspedisi),
"Beban Ekspedisi",
dto.ToFinancialMetrics(ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg, budgetEkspedisi),
dto.ToFinancialMetrics(ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg, totalEkspedisiRealization),
))
// HPP Summary
totalBudgetHpp := budgetPakan + budgetOvk + budgetAyam + budgetOperational + budgetEkspedisi
totalRealizationHpp := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization
hppBudgetRpPerBird, hppBudgetRpPerKg := calculateMetrics(totalBudgetHpp) hppBudgetRpPerBird, hppBudgetRpPerKg := calculateMetrics(totalBudgetHpp)
hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp) hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp)
var eggBudgeting, eggRealization *dto.FinancialMetrics var eggBudgeting, eggRealization *dto.FinancialMetrics
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) && totalEggWeightKg > 0 { if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
eggBudgetRpPerKg := totalBudgetHpp / totalEggWeightKg accumulateEggMetrics := func(metrics **dto.FinancialMetrics, amount, rpPerKg float64) {
eggRealizationRpPerKg := totalRealizationHpp / totalEggWeightKg if *metrics == nil {
eggBudgeting = &dto.FinancialMetrics{ *metrics = &dto.FinancialMetrics{
RpPerBird: 0, RpPerBird: 0,
RpPerKg: eggBudgetRpPerKg, RpPerKg: rpPerKg,
Amount: totalBudgetHpp, Amount: amount,
}
} else {
(*metrics).Amount += amount
if totalEggWeightKg > 0 {
(*metrics).RpPerKg = (*metrics).Amount / totalEggWeightKg
}
}
} }
eggRealization = &dto.FinancialMetrics{
RpPerBird: 0, for _, projectFlockKandang := range projectFlockKandangs {
RpPerKg: eggRealizationRpPerKg, hppResponse, err := s.HppSvc.CalculateHppCost(projectFlockKandang.Id, nil)
Amount: totalRealizationHpp, if err == nil {
accumulateEggMetrics(&eggBudgeting, hppResponse.Estimation.Total, hppResponse.Estimation.HargaKg)
accumulateEggMetrics(&eggRealization, hppResponse.Real.Total, hppResponse.Real.HargaKg)
}
} }
} }
@@ -543,15 +383,82 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl
eggRealization, eggRealization,
) )
hppSection := dto.ToHPPSection(hppItems, hppSummary) return dto.ToHPPSection(hppItems, hppSummary)
}
func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection {
totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg
totalSalesAmount := production.TotalSalesAmount
totalWeightSold := production.TotalWeightSold
totalBirdSold := production.TotalBirdSold
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying)
// Fungsi untuk sales: LAYING = populasi aktual, GROWING = ekor terjual
calculateSalesMetrics := 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
}
// Fungsi untuk cost: per ekor = populasi aktual, per kg = LAYING telur produksi / GROWING ayam produksi
calculateCostMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
}
if isLaying {
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
}
// Build Profit Loss Items using constants
plItems := []dto.ProfitLossItem{} plItems := []dto.ProfitLossItem{}
// SALES item salesRpPerBird, salesRpPerKg := calculateSalesMetrics(totalSalesAmount)
salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(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(
@@ -563,76 +470,100 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl
totalSalesAmount, totalSalesAmount,
)) ))
// SAPRONAK item - combines DOC/Depresiasi + PAKAN + OVK totalSapronakAmount := costs.ChickenCost + costs.FeedCost + costs.OvkCost
totalSapronakAmount := totalAyamPrice + totalPakanPrice + totalOvkPrice sapronakRpPerBird := 0.0
sapronakRpPerBird := docRealizationRpPerBird + pakanRealizationRpPerBird + ovkRealizationRpPerBird sapronakRpPerKg := 0.0
sapronakRpPerKg := docRealizationRpPerKg + pakanRealizationRpPerKg + ovkRealizationRpPerKg for _, amount := range []float64{costs.ChickenCost, costs.FeedCost, costs.OvkCost} {
sapronakLabel := "Pengeluaran Sapronak" rpPerBird, rpPerKg := calculateCostMetrics(amount)
sapronakRpPerBird += rpPerBird
sapronakRpPerKg += rpPerKg
}
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,
)) ))
// OVERHEAD item overheadRpPerBird, overheadRpPerKg := calculateOverheadMetrics(costs.RealizationOperational)
overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization)
plItems = append(plItems, dto.ToProfitLossItem( plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeOverhead), string(dto.PLCodeOverhead),
"Overhead", "Overhead",
"overhead", "overhead",
overheadRpPerBird, overheadRpPerBird,
overheadRpPerKg, overheadRpPerKg,
totalOperationalRealization, costs.RealizationOperational,
)) ))
// EKSPEDISI item ekspedisiRpPerBird, ekspedisiRpPerKg := calculateOverheadMetrics(costs.ExpeditionCost)
plItems = append(plItems, dto.ToProfitLossItem( plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeEkspedisi), string(dto.PLCodeEkspedisi),
"Ekspedisi", "Ekspedisi",
"overhead", "overhead",
ekspedisiRealizationRpPerBird, ekspedisiRpPerBird,
ekspedisiRealizationRpPerKg, ekspedisiRpPerKg,
totalEkspedisiRealization, costs.ExpeditionCost,
)) ))
// Profit Loss Summary costOfGoodsSold := costs.ChickenCost + costs.FeedCost + costs.OvkCost
// Gross Profit = Sales - (DOC + PAKAN + OVK) only
// Gross Profit should NOT include overhead and ekspedisi
costOfGoodsSold := totalAyamPrice + totalPakanPrice + totalOvkPrice
costOfGoodsSoldRpPerBird := sapronakRpPerBird costOfGoodsSoldRpPerBird := sapronakRpPerBird
costOfGoodsSoldRpPerKg := sapronakRpPerKg
grossProfit := totalSalesAmount - costOfGoodsSold grossProfit := totalSalesAmount - costOfGoodsSold
grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird
grossProfitRpPerKg := salesRpPerKg - costOfGoodsSoldRpPerKg
// Operating Expenses (Overhead + Ekspedisi) totalOperatingExpenses := costs.RealizationOperational + costs.ExpeditionCost
totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRpPerBird
totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird totalOperatingExpensesRpPerKg := overheadRpPerKg + ekspedisiRpPerKg
// Net Profit = Gross Profit - Operating Expenses
netProfit := grossProfit - totalOperatingExpenses netProfit := grossProfit - totalOperatingExpenses
netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird
netProfitRpPerKg := grossProfitRpPerKg - totalOperatingExpensesRpPerKg
plSummary := dto.ToProfitLossSummary( plSummary := dto.ToProfitLossSummary(
dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit), dto.ToFinancialMetrics(grossProfitRpPerBird, grossProfitRpPerKg, grossProfit),
dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, 0, totalOperatingExpenses), dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, totalOperatingExpensesRpPerKg, totalOperatingExpenses),
dto.ToFinancialMetrics(netProfitRpPerBird, 0, netProfit), dto.ToFinancialMetrics(netProfitRpPerBird, netProfitRpPerKg, netProfit),
) )
profitLossSection := dto.ToProfitLossSection(plItems, plSummary) return dto.ToProfitLossSection(plItems, plSummary)
// Build complete response
data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
return &data, nil
} }
// containsItem checks if a string exists in a slice func (s closingKeuanganService) getLastPopulationFromRecordings(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) (float64, bool) {
func containsItem(slice []string, item string) bool { if s.RecordingRepo == nil || len(projectFlockKandangs) == 0 {
for _, s := range slice { return 0, false
if strings.EqualFold(s, item) { }
total := 0.0
recordedCount := 0
for _, kandang := range projectFlockKandangs {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(c.Context(), kandang.Id)
if err != nil {
s.Log.Errorf("Failed to fetch latest recording for project_flock_kandang_id=%d: %+v", kandang.Id, err)
return 0, false
}
if latest == nil || latest.TotalChickQty == nil {
continue
}
recordedCount++
if *latest.TotalChickQty > 0 {
total += *latest.TotalChickQty
}
}
if recordedCount != len(projectFlockKandangs) {
return 0, false
}
return total, true
}
func containsFlag(flags []entity.Flag, name string) bool {
for _, flag := range flags {
if flag.Name == name {
return true return true
} }
} }
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -17,8 +18,8 @@ import (
) )
type SapronakService interface { type SapronakService interface {
GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error)
GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error)
} }
type sapronakService struct { type sapronakService struct {
@@ -41,9 +42,9 @@ func NewSapronakService(
} }
} }
func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) { func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) {
if projectFlockID == 0 { if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required")
} }
reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
ProjectFlockID: projectFlockID, ProjectFlockID: projectFlockID,
@@ -51,19 +52,27 @@ func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint,
Flag: flag, Flag: flag,
}) })
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
if len(reports) <= 1 { if len(reports) <= 1 {
return reports, nil flags, err := s.collectProductFlags(c.Context(), reports)
if err != nil {
return nil, nil, err
}
return reports, flags, nil
} }
combined := s.combineSapronakReports(reports, projectFlockID) combined := s.combineSapronakReports(reports, projectFlockID)
return []dto.SapronakReportDTO{combined}, nil flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{combined})
if err != nil {
return nil, nil, err
}
return []dto.SapronakReportDTO{combined}, flags, nil
} }
func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) { func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) {
if projectFlockID == 0 || pfkID == 0 { if projectFlockID == 0 || pfkID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required")
} }
results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
@@ -73,16 +82,20 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint,
Flag: flag, Flag: flag,
}) })
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
for _, res := range results { for _, res := range results {
if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID { if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID {
return &res, nil flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{res})
if err != nil {
return nil, nil, err
}
return &res, flags, nil
} }
} }
return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") return nil, nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found")
} }
func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) {
@@ -111,7 +124,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
} }
// We no longer filter by date for closing sapronak report; pass nil pointers. // We no longer filter by date for closing sapronak report; pass nil pointers.
items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag)
if err != nil { if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report")
@@ -135,6 +148,52 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
return results, nil return results, nil
} }
func (s sapronakService) collectProductFlags(ctx context.Context, reports []dto.SapronakReportDTO) (map[uint][]string, error) {
productIDs := make(map[uint]struct{})
for _, report := range reports {
for _, group := range report.Groups {
for _, item := range group.Items {
if item.ProductID > 0 {
productIDs[item.ProductID] = struct{}{}
}
}
}
}
if len(productIDs) == 0 {
return map[uint][]string{}, nil
}
ids := make([]uint, 0, len(productIDs))
for id := range productIDs {
ids = append(ids, id)
}
products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, ids)
if err != nil {
return nil, err
}
result := make(map[uint][]string, len(products))
for _, product := range products {
if len(product.Flags) == 0 {
continue
}
flags := make([]string, 0, len(product.Flags))
for _, flag := range product.Flags {
name := strings.TrimSpace(flag.Name)
if name == "" {
continue
}
flags = append(flags, strings.ToUpper(name))
}
if len(flags) > 0 {
result[product.Id] = flags
}
}
return result, nil
}
func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) {
db := s.ProjectFlockKandangRepo.DB().WithContext(ctx). db := s.ProjectFlockKandangRepo.DB().WithContext(ctx).
Preload("ProjectFlock"). Preload("ProjectFlock").
@@ -262,6 +321,7 @@ type sapronakDetailMaps struct {
AdjOutgoing map[uint][]dto.SapronakDetailDTO AdjOutgoing map[uint][]dto.SapronakDetailDTO
TransferIn map[uint][]dto.SapronakDetailDTO TransferIn map[uint][]dto.SapronakDetailDTO
TransferOut map[uint][]dto.SapronakDetailDTO TransferOut map[uint][]dto.SapronakDetailDTO
SalesOut map[uint][]dto.SapronakDetailDTO
} }
func buildSapronakDetails( func buildSapronakDetails(
@@ -271,6 +331,7 @@ func buildSapronakDetails(
adjOutgoingRows map[uint][]repository.SapronakDetailRow, adjOutgoingRows map[uint][]repository.SapronakDetailRow,
transferInRows map[uint][]repository.SapronakDetailRow, transferInRows map[uint][]repository.SapronakDetailRow,
transferOutRows map[uint][]repository.SapronakDetailRow, transferOutRows map[uint][]repository.SapronakDetailRow,
salesOutRows map[uint][]repository.SapronakDetailRow,
) sapronakDetailMaps { ) sapronakDetailMaps {
result := sapronakDetailMaps{ result := sapronakDetailMaps{
Incoming: make(map[uint][]dto.SapronakDetailDTO), Incoming: make(map[uint][]dto.SapronakDetailDTO),
@@ -279,6 +340,7 @@ func buildSapronakDetails(
AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO), AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO),
TransferIn: make(map[uint][]dto.SapronakDetailDTO), TransferIn: make(map[uint][]dto.SapronakDetailDTO),
TransferOut: make(map[uint][]dto.SapronakDetailDTO), TransferOut: make(map[uint][]dto.SapronakDetailDTO),
SalesOut: make(map[uint][]dto.SapronakDetailDTO),
} }
addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) { addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) {
@@ -311,6 +373,7 @@ func buildSapronakDetails(
addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false) addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false)
addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true) addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true)
addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false) addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false)
addRows(result.SalesOut, salesOutRows, "Penjualan", false)
return result return result
} }
@@ -342,6 +405,14 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
if len(usageAllocatedDetails) > 0 {
usageDetailsRows = usageAllocatedDetails
chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{}
}
adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId) adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
@@ -350,6 +421,10 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter)) filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter))
matchesFlag := func(f string) bool { matchesFlag := func(f string) bool {
@@ -362,6 +437,34 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
} }
return candidate == filterFlag return candidate == filterFlag
} }
dedupTransfers := func(src map[uint][]dto.SapronakDetailDTO) map[uint][]dto.SapronakDetailDTO {
result := make(map[uint][]dto.SapronakDetailDTO, len(src))
seen := make(map[string]struct{})
for pid, rows := range src {
for _, d := range rows {
dateKey := ""
if d.Tanggal != nil {
dateKey = d.Tanggal.Format("2006-01-02")
}
qtyKey := d.QtyMasuk
if qtyKey == 0 {
qtyKey = d.QtyKeluar
}
ref := strings.TrimSpace(d.NoReferensi)
key := fmt.Sprintf("%d|%s|%s|%.3f", pid, ref, dateKey, qtyKey)
if ref == "" {
key = fmt.Sprintf("%d|%s|%s|%.3f|%s", pid, ref, dateKey, qtyKey, strings.ToUpper(strings.TrimSpace(d.Flag)))
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result[pid] = append(result[pid], d)
}
}
return result
}
// For project flocks with category GROWING, pullet usage from chickin // For project flocks with category GROWING, pullet usage from chickin
// should not be counted yet. Only when category is LAYING we allow // should not be counted yet. Only when category is LAYING we allow
@@ -400,13 +503,17 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
usageDetailsRows[pid] = append(usageDetailsRows[pid], rows...) usageDetailsRows[pid] = append(usageDetailsRows[pid], rows...)
} }
detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows) detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows, salesOutRows)
incomingDetails := detailMaps.Incoming incomingDetails := detailMaps.Incoming
usageDetails := detailMaps.Usage usageDetails := detailMaps.Usage
adjIncoming := detailMaps.AdjIncoming adjIncoming := detailMaps.AdjIncoming
adjOutgoing := detailMaps.AdjOutgoing adjOutgoing := detailMaps.AdjOutgoing
transIncoming := detailMaps.TransferIn transIncoming := detailMaps.TransferIn
transOutgoing := detailMaps.TransferOut transOutgoing := detailMaps.TransferOut
salesOutgoing := detailMaps.SalesOut
transIncoming = dedupTransfers(transIncoming)
transOutgoing = dedupTransfers(transOutgoing)
ensureGroup := func(flag string) *dto.SapronakGroupDTO { ensureGroup := func(flag string) *dto.SapronakGroupDTO {
if g, ok := groupMap[flag]; ok { if g, ok := groupMap[flag]; ok {
@@ -521,13 +628,12 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if existing.ProductName == "" { if existing.ProductName == "" {
existing.ProductName = d.ProductName existing.ProductName = d.ProductName
} }
existing.UsageQty += d.QtyKeluar // Adjustment keluar should reduce stock without inflating usage-based HPP.
existing.UsageValue += d.Nilai remaining := existing.IncomingQty - existing.UsageQty - d.QtyKeluar
if existing.IncomingQty >= existing.UsageQty { if remaining < 0 {
existing.RemainingQty = existing.IncomingQty - existing.UsageQty remaining = 0
} else {
existing.RemainingQty = 0
} }
existing.RemainingQty = remaining
itemMap[productID] = existing itemMap[productID] = existing
} }
} }
@@ -683,6 +789,25 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
} }
} }
for productID, details := range salesOutgoing {
flag, name := resolveFlagName(productID, details)
if !matchesFlag(flag) {
continue
}
group := ensureGroup(flag)
for _, d := range details {
if d.Flag == "" {
d.Flag = flag
}
if d.ProductName == "" {
d.ProductName = name
}
group.Items = append(group.Items, d)
group.TotalKeluar += d.QtyKeluar
group.SaldoAkhir -= d.QtyKeluar
}
}
groups := make([]dto.SapronakGroupDTO, 0, len(groupMap)) groups := make([]dto.SapronakGroupDTO, 0, len(groupMap))
for _, g := range groupMap { for _, g := range groupMap {
groups = append(groups, *g) groups = append(groups, *g)
@@ -9,9 +9,11 @@ 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,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
ProjectStatus *int `query:"project_status" validate:"omitempty,oneof=1 2"`
LocationID *uint `query:"location_id" validate:"omitempty,gt=0"`
} }
const ( const (
@@ -24,4 +26,5 @@ type ClosingSapronakQuery struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
KandangID *uint `query:"kandang_id" validate:"omitempty,gt=0"` KandangID *uint `query:"kandang_id" validate:"omitempty,gt=0"`
Search string `query:"search" validate:"omitempty,max=100"`
} }
@@ -83,7 +83,7 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} {
"KANDANG", "KANDANG",
}, },
"stock_log": map[string][]string{ "stock_log": map[string][]string{
"log_types": []string{"TRANSFER", "ADJUSTMENT"}, "log_types": []string{"TRANSFER", "ADJUSTMENT", "MARKETING", "CHICKIN", "PURCHASE", "RECORDING"},
"transaction_types": []string{"INCREASE", "DECREASE"}, "transaction_types": []string{"INCREASE", "DECREASE"},
}, },
"supplier_categories": []string{ "supplier_categories": []string{
@@ -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 {
@@ -52,7 +52,7 @@ type SummaryQuery struct {
type ReportQuery struct { type ReportQuery struct {
Page int `query:"page" validate:"required,number,min=1,gt=0"` Page int `query:"page" validate:"required,number,min=1,gt=0"`
Limit int `query:"limit" validate:"required,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"required,number,min=1,gt=0"`
Month int `query:"bulan" validate:"required,number,min=1,max=12"` Month int `query:"bulan" validate:"required,number,min=1,max=12"`
Year int `query:"tahun" validate:"required,number,min=1900"` Year int `query:"tahun" validate:"required,number,min=1900"`
AreaID *uint `query:"area_id" validate:"omitempty"` AreaID *uint `query:"area_id" validate:"omitempty"`
@@ -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)
+5 -2
View File
@@ -5,6 +5,8 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" rDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories"
sDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" sDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services"
@@ -16,11 +18,12 @@ type DashboardModule struct{}
func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dashboardRepo := rDashboard.NewDashboardRepository(db) dashboardRepo := rDashboard.NewDashboardRepository(db)
hppCostRepo := commonRepo.NewHppCostRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate) hppSvc := commonService.NewHppService(hppCostRepo)
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppSvc)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
DashboardRoutes(router, userService, dashboardService) DashboardRoutes(router, userService, dashboardService)
} }
@@ -21,6 +21,7 @@ type DashboardRepository interface {
SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error) SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error)
SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error)
SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error) SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error)
ListProjectFlockKandangIDsByEggProduction(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]uint, error)
GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error)
GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error)
GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error) GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error)
@@ -107,16 +107,23 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go
func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) { func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) {
var rows []RecordingWeeklyMetric var rows []RecordingWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recordings AS r"). Table("recordings AS r").
Select(`((r.day - 1) / 7 + 1) AS week, Select(fmt.Sprintf(`%s AS week,
COALESCE(AVG(r.hen_day), 0) AS hen_day, COALESCE(AVG(r.hen_day), 0) AS hen_day,
COALESCE(AVG(r.egg_weight), 0) AS egg_weight, COALESCE(AVG(r.egg_weight), 0) AS egg_weight,
COALESCE(AVG(r.feed_intake), 0) AS feed_intake, COALESCE(AVG(r.feed_intake), 0) AS feed_intake,
COALESCE(AVG(r.fcr_value), 0) AS fcr_value, COALESCE(AVG(r.fcr_value), 0) AS fcr_value,
COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`). COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`, weekExpr)).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL"). Where("r.deleted_at IS NULL").
Where("r.day IS NOT NULL AND r.day > 0") Where("r.day IS NOT NULL AND r.day > 0")
@@ -188,92 +195,19 @@ func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, week
return nil, nil return nil, nil
} }
filterClause := "" standardIDs := r.standardIDSubquery(filters)
filterArgs := make([]interface{}, 0) if standardIDs == nil {
if filters != nil { return nil, nil
if len(filters.FlockIds) > 0 {
filterClause += " AND pf.id IN ?"
filterArgs = append(filterArgs, filters.FlockIds)
}
if len(filters.KandangIds) > 0 {
filterClause += " AND k.id IN ?"
filterArgs = append(filterArgs, filters.KandangIds)
}
if len(filters.LokasiIds) > 0 {
filterClause += " AND k.location_id IN ?"
filterArgs = append(filterArgs, filters.LokasiIds)
}
} }
query := fmt.Sprintf(`
WITH src AS (
SELECT DISTINCT pf.production_standard_id, pf.fcr_id
FROM project_flocks pf
JOIN project_flock_kandangs pfk ON pfk.project_flock_id = pf.id
JOIN kandangs k ON k.id = pfk.kandang_id
WHERE pf.production_standard_id > 0 AND pf.fcr_id > 0
%s
),
actual AS (
SELECT u.week AS week,
pf.fcr_id AS fcr_id,
AVG((u.chart_data->'statistics'->>'average_weight')::numeric) AS avg_weight
FROM project_flock_kandang_uniformity u
JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
JOIN kandangs k ON k.id = pfk.kandang_id
WHERE u.week IN ? AND u.uniform_date IS NOT NULL AND pf.fcr_id > 0
%s
GROUP BY u.week, pf.fcr_id
),
target AS (
SELECT sgd.week AS week,
src.fcr_id AS fcr_id,
AVG(sgd.target_mean_bw) AS target_mean_bw
FROM standard_growth_details sgd
JOIN src ON src.production_standard_id = sgd.production_standard_id
WHERE sgd.week IN ?
GROUP BY sgd.week, src.fcr_id
),
weights AS (
SELECT COALESCE(a.week, t.week) AS week,
COALESCE(a.fcr_id, t.fcr_id) AS fcr_id,
COALESCE(
CASE WHEN a.avg_weight > 10 THEN a.avg_weight / 1000 ELSE a.avg_weight END,
CASE WHEN t.target_mean_bw > 10 THEN t.target_mean_bw / 1000 ELSE t.target_mean_bw END
) AS weight
FROM actual a
FULL OUTER JOIN target t ON t.week = a.week AND t.fcr_id = a.fcr_id
)
SELECT w.week AS week,
COALESCE(AVG(
COALESCE(
(SELECT fs.fcr_number
FROM fcr_standards fs
WHERE fs.fcr_id = w.fcr_id
AND fs.weight >= w.weight
ORDER BY fs.weight ASC
LIMIT 1),
(SELECT fs.fcr_number
FROM fcr_standards fs
WHERE fs.fcr_id = w.fcr_id
ORDER BY fs.weight DESC
LIMIT 1)
)
), 0) AS std_fcr
FROM weights w
GROUP BY w.week
ORDER BY w.week ASC
`, filterClause, filterClause)
args := make([]interface{}, 0, len(filterArgs)*2+2)
args = append(args, filterArgs...)
args = append(args, weeks)
args = append(args, filterArgs...)
args = append(args, weeks)
var rows []StandardWeeklyFcrMetric var rows []StandardWeeklyFcrMetric
if err := r.DB().WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { db := r.DB().WithContext(ctx).
Table("production_standard_details AS psd").
Select("psd.week AS week, COALESCE(AVG(psd.standard_fcr), 0) AS std_fcr").
Where("psd.week IN ?", weeks).
Where("psd.production_standard_id IN (?)", standardIDs)
if err := db.Group("psd.week").Order("psd.week ASC").Scan(&rows).Error; err != nil {
return nil, err return nil, err
} }
@@ -285,7 +219,7 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightGrams(ctx context.Contex
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recording_eggs AS re"). Table("recording_eggs AS re").
Select("COALESCE(SUM(re.qty * re.weight), 0)"). Select("COALESCE(SUM(re.weight * 1000), 0)").
Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
@@ -309,6 +243,27 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightKg(ctx context.Context,
return grams / 1000, nil return grams / 1000, nil
} }
func (r *DashboardRepositoryImpl) ListProjectFlockKandangIDsByEggProduction(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]uint, error) {
var ids []uint
db := r.DB().WithContext(ctx).
Table("recording_eggs AS re").
Select("DISTINCT r.project_flock_kandangs_id").
Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
db = applyDashboardFilters(db, filters)
if err := db.Scan(&ids).Error; err != nil {
return nil, err
}
return ids, nil
}
func (r *DashboardRepositoryImpl) GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) { func (r *DashboardRepositoryImpl) GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) {
var rows []FeedUsageByUom var rows []FeedUsageByUom
@@ -489,30 +444,6 @@ func (r *DashboardRepositoryImpl) standardIDSubquery(filters *validation.Dashboa
return db return db
} }
func (r *DashboardRepositoryImpl) standardSourceSubquery(filters *validation.DashboardFilter) *gorm.DB {
db := r.DB().
Table("project_flocks AS pf").
Select("DISTINCT pf.production_standard_id, pf.fcr_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Where("pf.production_standard_id > 0").
Where("pf.fcr_id > 0")
if filters != nil {
if len(filters.FlockIds) > 0 {
db = db.Where("pf.id IN ?", filters.FlockIds)
}
if len(filters.KandangIds) > 0 {
db = db.Where("k.id IN ?", filters.KandangIds)
}
if len(filters.LokasiIds) > 0 {
db = db.Where("k.location_id IN ?", filters.LokasiIds)
}
}
return db
}
func (r *DashboardRepositoryImpl) GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) { func (r *DashboardRepositoryImpl) GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) {
seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType)
if err != nil { if err != nil {
@@ -551,18 +482,23 @@ 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(`((r.day - 1) / 7 + 1) 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").
Joins("JOIN locations AS loc ON loc.id = k.location_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL"). Where("r.deleted_at IS NULL")
Where("r.day IS NOT NULL AND r.day > 0")
db = applyDashboardFilters(db, filters) db = applyDashboardFilters(db, filters)
@@ -609,13 +545,19 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte
func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) { func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) {
var rows []EggQualityWeeklyMetric var rows []EggQualityWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recording_eggs AS re"). Table("recording_eggs AS re").
Select(` Select(fmt.Sprintf(`
((r.day - 1) / 7 + 1) AS week, %s AS week,
COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty, COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty,
COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty, COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty,
COALESCE(SUM(re.qty), 0) AS total_qty`, COALESCE(SUM(re.qty), 0) AS total_qty`, weekExpr),
utils.FlagTelurUtuh, utils.FlagTelurUtuh,
utils.FlagTelurPutih, utils.FlagTelurPutih,
utils.FlagTelurRetak, utils.FlagTelurRetak,
@@ -624,6 +566,7 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id"). Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
@@ -644,14 +587,21 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) { func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) {
var rows []WeeklyEggWeightMetric var rows []WeeklyEggWeightMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recording_eggs AS re"). Table("recording_eggs AS re").
Select(` Select(fmt.Sprintf(`
((r.day - 1) / 7 + 1) AS week, %s AS week,
COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`). COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`, weekExpr)).
Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL"). Where("r.deleted_at IS NULL").
Where("r.day IS NOT NULL AND r.day > 0") Where("r.day IS NOT NULL AND r.day > 0")
@@ -668,15 +618,22 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s
func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) { func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) {
var rows []WeeklyFeedUsageMetric var rows []WeeklyFeedUsageMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recording_stocks AS rs"). Table("recording_stocks AS rs").
Select(` Select(fmt.Sprintf(`
((r.day - 1) / 7 + 1) AS week, %s AS week,
COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty,
LOWER(uoms.name) AS uom_name`). LOWER(uoms.name) AS uom_name`, weekExpr)).
Joins("JOIN recordings AS r ON r.id = rs.recording_id"). Joins("JOIN recordings AS r ON r.id = rs.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("JOIN uoms ON uoms.id = p.uom_id"). Joins("JOIN uoms ON uoms.id = p.uom_id").
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
commonService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
@@ -17,26 +18,34 @@ 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 {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
Repository repository.DashboardRepository Repository repository.DashboardRepository
HppSvc commonService.HppService
} }
func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate) DashboardService { func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate, hppSvc commonService.HppService) DashboardService {
return &dashboardService{ return &dashboardService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
HppSvc: hppSvc,
} }
} }
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
@@ -592,13 +601,13 @@ func buildAggregateComparisonPercent(weeks []int, seriesRows []repository.Compar
count++ count++
} }
if count == 0 {
continue
}
if result[week] == nil { if result[week] == nil {
result[week] = map[uint]float64{} result[week] = map[uint]float64{}
} }
if count == 0 {
result[week][series.Id] = 0
continue
}
result[week][series.Id] = sum / count result[week][series.Id] = sum / count
} }
} }
@@ -846,6 +855,21 @@ func percentDelta(current, last float64) float64 {
} }
func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) {
if s.HppSvc != nil {
currentHpp, err := s.hppGlobalForPeriod(ctx, startDate, endExclusive)
if err != nil {
return 0, 0, err
}
lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location)
lastHpp, err := s.hppGlobalForPeriod(ctx, lastMonthStart, lastMonthEndExclusive)
if err != nil {
return 0, 0, err
}
return currentHpp, lastHpp, nil
}
totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, nil) totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, nil)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
@@ -878,6 +902,37 @@ func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, end
return hppCurrent, hppLast, nil return hppCurrent, hppLast, nil
} }
func (s dashboardService) hppGlobalForPeriod(ctx context.Context, startDate, endExclusive time.Time) (float64, error) {
kandangIDs, err := s.Repository.ListProjectFlockKandangIDsByEggProduction(ctx, startDate, endExclusive, nil)
if err != nil {
return 0, err
}
if len(kandangIDs) == 0 {
return 0, nil
}
endOfPeriod := endExclusive.Add(-time.Nanosecond)
totalCost := 0.0
totalWeightKg := 0.0
for _, kandangID := range kandangIDs {
hppCost, err := s.HppSvc.CalculateHppCost(kandangID, &endOfPeriod)
if err != nil {
return 0, err
}
if hppCost == nil {
continue
}
totalCost += hppCost.Estimation.Total
totalWeightKg += hppCost.Estimation.Kg
}
if totalWeightKg <= 0 {
return 0, nil
}
return totalCost / totalWeightKg, nil
}
func (s dashboardService) calculateSellingPrice(ctx context.Context, endDate time.Time, location *time.Location) (float64, float64, error) { func (s dashboardService) calculateSellingPrice(ctx context.Context, endDate time.Time, location *time.Location) (float64, float64, error) {
startPrevMonth, endPrevMonthExclusive := monthRange(endDate.AddDate(0, -1, 0), location) startPrevMonth, endPrevMonthExclusive := monthRange(endDate.AddDate(0, -1, 0), location)
currentEndExclusive := endDate.AddDate(0, 0, 1) currentEndExclusive := endDate.AddDate(0, 0, 1)
@@ -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)
} }
} }
@@ -70,7 +70,8 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id").
Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id").
Where("expenses.realization_date IS NOT NULL") Where("expenses.realization_date IS NOT NULL").
Where("expenses.category = ?", "BOP")
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
db = db.Where(`( db = db.Where(`(
@@ -138,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) {
@@ -20,7 +20,7 @@ type InitialRelationDTO struct {
InitialBalanceType string `json:"initial_balance_type"` InitialBalanceType string `json:"initial_balance_type"`
InitialBalanceTypeLabel string `json:"initial_balance_type_label"` InitialBalanceTypeLabel string `json:"initial_balance_type_label"`
Party Party `json:"party"` Party Party `json:"party"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` Bank *bankDTO.BankRelationDTO `json:"bank"`
Direction string `json:"direction"` Direction string `json:"direction"`
Nominal float64 `json:"nominal"` Nominal float64 `json:"nominal"`
Notes string `json:"notes"` Notes string `json:"notes"`
@@ -128,11 +128,12 @@ func partyFromInitial(e entity.Payment) Party {
return party return party
} }
func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO { func bankFromInitial(e entity.Payment) *bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 { if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{} return nil
} }
return bankDTO.ToBankRelationDTO(e.BankWarehouse) bank := bankDTO.ToBankRelationDTO(e.BankWarehouse)
return &bank
} }
func userFromInitial(e entity.Payment) userDTO.UserRelationDTO { func userFromInitial(e entity.Payment) userDTO.UserRelationDTO {
@@ -161,7 +162,7 @@ func initialBalanceLabel(balanceType string) string {
} }
func initialBalanceTypeFromPayment(e entity.Payment) string { func initialBalanceTypeFromPayment(e entity.Payment) string {
if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 { if e.Nominal < 0 {
return "NEGATIVE" return "NEGATIVE"
} }
return "POSITIVE" return "POSITIVE"
@@ -82,6 +82,7 @@ func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
} }
func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) {
normalizeOptionalBankId(&req.BankId)
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
@@ -124,7 +125,7 @@ func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
PaymentDate: time.Now(), PaymentDate: time.Now(),
PaymentMethod: string(utils.PaymentMethodSaldo), PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId, BankId: req.BankId,
Direction: directionForInitialType(balanceType), Direction: directionForInitialType(party, balanceType),
Nominal: signedNominal(balanceType, req.Nominal), Nominal: signedNominal(balanceType, req.Nominal),
Notes: req.Note, Notes: req.Note,
CreatedBy: actorID, CreatedBy: actorID,
@@ -164,6 +165,7 @@ func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
} }
func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) {
normalizeOptionalBankId(&req.BankId)
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
@@ -186,6 +188,8 @@ func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil
requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil
var existing *entity.Payment var existing *entity.Payment
var resolvedPartyType string
var resolvedPartyId uint
if requiresVerification { if requiresVerification {
current, err := s.Repository.GetByID(c.Context(), id, nil) current, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -199,26 +203,25 @@ func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
} }
existing = current existing = current
resolvedPartyType = existing.PartyType
resolvedPartyId = existing.PartyId
} }
if req.PartyType != nil || req.PartyId != nil { if req.PartyType != nil || req.PartyId != nil {
partyType := existing.PartyType
partyId := existing.PartyId
if req.PartyType != nil { if req.PartyType != nil {
normalized, err := normalizePartyType(*req.PartyType) normalized, err := normalizePartyType(*req.PartyType)
if err != nil { if err != nil {
return nil, err return nil, err
} }
partyType = normalized resolvedPartyType = normalized
updateBody["party_type"] = partyType updateBody["party_type"] = resolvedPartyType
} }
if req.PartyId != nil { if req.PartyId != nil {
partyId = *req.PartyId resolvedPartyId = *req.PartyId
updateBody["party_id"] = partyId updateBody["party_id"] = resolvedPartyId
} }
if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { if err := s.ensurePartyExists(c.Context(), resolvedPartyType, resolvedPartyId); err != nil {
return nil, err return nil, err
} }
} }
@@ -238,8 +241,11 @@ func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
nominal = *req.Nominal nominal = *req.Nominal
} }
updateBody["direction"] = directionForInitialType(balanceType) updateBody["direction"] = directionForInitialType(resolvedPartyType, balanceType)
updateBody["nominal"] = signedNominal(balanceType, nominal) updateBody["nominal"] = signedNominal(balanceType, nominal)
} else if req.PartyType != nil {
balanceType := balanceTypeFromPayment(existing)
updateBody["direction"] = directionForInitialType(resolvedPartyType, balanceType)
} }
if len(updateBody) == 0 { if len(updateBody) == 0 {
@@ -262,7 +268,7 @@ func isInitialTransaction(transactionType string) bool {
} }
func balanceTypeFromPayment(payment *entity.Payment) string { func balanceTypeFromPayment(payment *entity.Payment) string {
if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 { if payment.Nominal < 0 {
return "NEGATIVE" return "NEGATIVE"
} }
return "POSITIVE" return "POSITIVE"
@@ -286,11 +292,24 @@ func normalizeInitialBalanceType(balanceType string) (string, error) {
} }
} }
func directionForInitialType(balanceType string) string { func directionForInitialType(partyType string, balanceType string) string {
if strings.EqualFold(balanceType, "NEGATIVE") { switch utils.PaymentParty(strings.ToUpper(strings.TrimSpace(partyType))) {
return "OUT" case utils.PaymentPartySupplier:
if strings.EqualFold(balanceType, "POSITIVE") {
return "OUT"
}
return "IN"
case utils.PaymentPartyCustomer:
if strings.EqualFold(balanceType, "NEGATIVE") {
return "OUT"
}
return "IN"
default:
if strings.EqualFold(balanceType, "NEGATIVE") {
return "OUT"
}
return "IN"
} }
return "IN"
} }
func signedNominal(balanceType string, nominal float64) float64 { func signedNominal(balanceType string, nominal float64) float64 {
@@ -335,3 +354,12 @@ func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) erro
commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists},
) )
} }
func normalizeOptionalBankId(bankId **uint) {
if bankId == nil || *bankId == nil {
return
}
if **bankId == 0 {
*bankId = nil
}
}
@@ -3,7 +3,7 @@ package validation
type Create struct { type Create struct {
PartyType string `json:"party_type" validate:"required_strict,max=50"` PartyType string `json:"party_type" validate:"required_strict,max=50"`
PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"`
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"` ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"`
InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"` InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` Nominal float64 `json:"nominal" validate:"required_strict,gt=0"`
@@ -110,7 +110,7 @@ func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
PaymentDate: adjustmentDate, PaymentDate: adjustmentDate,
PaymentMethod: string(utils.PaymentMethodSaldo), PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId, BankId: req.BankId,
Direction: "IN", Direction: directionForInjectionNominal(req.Nominal),
Nominal: req.Nominal, Nominal: req.Nominal,
Notes: req.Notes, Notes: req.Notes,
CreatedBy: actorID, CreatedBy: actorID,
@@ -186,6 +186,7 @@ func (s injectionService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
if req.Nominal != nil { if req.Nominal != nil {
updateBody["nominal"] = *req.Nominal updateBody["nominal"] = *req.Nominal
updateBody["direction"] = directionForInjectionNominal(*req.Nominal)
} }
if req.Notes != nil { if req.Notes != nil {
updateBody["notes"] = *req.Notes updateBody["notes"] = *req.Notes
@@ -210,6 +211,13 @@ func isInjectionTransaction(transactionType string) bool {
return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection))
} }
func directionForInjectionNominal(nominal float64) string {
if nominal < 0 {
return "OUT"
}
return "IN"
}
func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) { func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) {
sequence, err := s.Repository.NextPaymentSequence(ctx) sequence, err := s.Repository.NextPaymentSequence(ctx)
if err != nil { if err != nil {
@@ -3,14 +3,14 @@ package validation
type Create struct { type Create struct {
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
AdjustmentDate string `json:"adjustment_date" validate:"required_strict"` AdjustmentDate string `json:"adjustment_date" validate:"required_strict"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` Nominal float64 `json:"nominal" validate:"required_strict"`
Notes string `json:"notes" validate:"required_strict,max=500"` Notes string `json:"notes" validate:"required_strict,max=500"`
} }
type Update struct { type Update struct {
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"` AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` Nominal *float64 `json:"nominal,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
} }
@@ -3,6 +3,7 @@ package controller
import ( import (
"math" "math"
"strconv" "strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
@@ -23,10 +24,81 @@ func NewTransactionController(transactionService service.TransactionService) *Tr
} }
func (u *TransactionController) GetAll(c *fiber.Ctx) error { func (u *TransactionController) GetAll(c *fiber.Ctx) error {
parseUintListParam := func(key string) ([]uint, error) {
raw := strings.TrimSpace(c.Query(key, ""))
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
if parsed == 0 {
continue
}
ids = append(ids, uint(parsed))
}
if len(ids) == 0 {
return nil, nil
}
return ids, nil
}
parseStringListParam := func(key string) ([]string, error) {
raw := strings.TrimSpace(c.Query(key, ""))
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
values = append(values, trimmed)
}
if len(values) == 0 {
return nil, nil
}
return values, nil
}
bankIDs, err := parseUintListParam("bank_ids")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid bank_ids")
}
customerIDs, err := parseUintListParam("customer_ids")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid customer_ids")
}
supplierIDs, err := parseUintListParam("supplier_ids")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid supplier_ids")
}
transactionTypes, err := parseStringListParam("transaction_types")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_types")
}
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: c.Query("search", ""),
TransactionTypes: transactionTypes,
BankIDs: bankIDs,
CustomerIDs: customerIDs,
SupplierIDs: supplierIDs,
SortDate: c.Query("sort_date", ""),
StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -21,7 +21,7 @@ type TransactionRelationDTO struct {
Party Party `json:"party"` Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"` PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"` PaymentMethod string `json:"payment_method"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` Bank *bankDTO.BankRelationDTO `json:"bank"`
ExpenseAmount float64 `json:"expense_amount"` ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"` IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"` Nominal float64 `json:"nominal"`
@@ -37,7 +37,7 @@ type TransactionListDTO struct {
Party Party `json:"party"` Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"` PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"` PaymentMethod string `json:"payment_method"`
Bank bankDTO.BankRelationDTO `json:"bank"` Bank *bankDTO.BankRelationDTO `json:"bank"`
ExpenseAmount float64 `json:"expense_amount"` ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"` IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"` Nominal float64 `json:"nominal"`
@@ -151,11 +151,12 @@ func partyFromPayment(e entity.Payment) Party {
return party return party
} }
func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { func bankFromPayment(e entity.Payment) *bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 { if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{} return nil
} }
return bankDTO.ToBankRelationDTO(e.BankWarehouse) bank := bankDTO.ToBankRelationDTO(e.BankWarehouse)
return &bank
} }
func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { func userFromPayment(e entity.Payment) userDTO.UserRelationDTO {
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"strings" "strings"
"time"
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"
@@ -61,21 +62,81 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
return nil, 0, err return nil, 0, err
} }
startDate, endDate, err := parseTransactionDateRange(params.StartDate, params.EndDate)
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
if params.Search != "" { if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
return db.Where( db = db.Joins(
"LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL",
string(utils.PaymentPartyCustomer),
).Joins(
"LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL",
string(utils.PaymentPartySupplier),
).Joins(
"LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL",
)
db = db.Where(
`LOWER(payment_code) LIKE ? OR `LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(payment_method, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR LOWER(COALESCE(transaction_type, '')) LIKE ? OR
LOWER(COALESCE(notes, '')) LIKE ?`, LOWER(COALESCE(notes, '')) LIKE ? OR
like, like, like, like, LOWER(COALESCE(customers.name, '')) LIKE ? OR
LOWER(COALESCE(suppliers.name, '')) LIKE ? OR
LOWER(COALESCE(banks.name, '')) LIKE ?`,
like, like, like, like, like, like, like, like,
) )
} }
return db.Order("payment_date DESC").Order("created_at DESC")
if len(params.TransactionTypes) > 0 {
types := make([]string, 0, len(params.TransactionTypes))
for _, transactionType := range params.TransactionTypes {
normalized := strings.ToUpper(strings.TrimSpace(transactionType))
if normalized == "" {
continue
}
types = append(types, normalized)
}
if len(types) > 0 {
db = db.Where("transaction_type IN ?", types)
}
}
if len(params.BankIDs) > 0 {
db = db.Where("bank_id IN ?", params.BankIDs)
}
customerIDs := params.CustomerIDs
supplierIDs := params.SupplierIDs
if len(customerIDs) > 0 && len(supplierIDs) > 0 {
db = db.Where(
"(party_type = ? AND party_id IN ?) OR (party_type = ? AND party_id IN ?)",
string(utils.PaymentPartyCustomer), customerIDs,
string(utils.PaymentPartySupplier), supplierIDs,
)
} else if len(customerIDs) > 0 {
db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartyCustomer), customerIDs)
} else if len(supplierIDs) > 0 {
db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartySupplier), supplierIDs)
}
if startDate != nil {
db = db.Where("payment_date >= ?", *startDate)
}
if endDate != nil {
db = db.Where("payment_date < ?", *endDate)
}
return applyTransactionSort(db, params.SortDate)
}) })
if err != nil { if err != nil {
@@ -173,3 +234,47 @@ func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return db.Preload("ActionUser") return db.Preload("ActionUser")
} }
} }
func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Time, error) {
start := strings.TrimSpace(startDate)
end := strings.TrimSpace(endDate)
var startPtr *time.Time
var endPtr *time.Time
var endValue *time.Time
if start != "" {
parsed, err := utils.ParseDateString(start)
if err != nil {
return nil, nil, utils.BadRequest("start_date must use format YYYY-MM-DD")
}
startPtr = &parsed
}
if end != "" {
parsed, err := utils.ParseDateString(end)
if err != nil {
return nil, nil, utils.BadRequest("end_date must use format YYYY-MM-DD")
}
endValue = &parsed
nextDay := parsed.AddDate(0, 0, 1)
endPtr = &nextDay
}
if startPtr != nil && endValue != nil && startPtr.After(*endValue) {
return nil, nil, utils.BadRequest("start_date must be earlier than end_date")
}
return startPtr, endPtr, nil
}
func applyTransactionSort(db *gorm.DB, sortDate string) *gorm.DB {
switch strings.ToLower(strings.TrimSpace(sortDate)) {
case "created_at":
return db.Order("created_at DESC").Order("payment_date DESC")
case "payment_date":
return db.Order("payment_date DESC").Order("created_at DESC")
default:
return db.Order("payment_date DESC").Order("created_at DESC")
}
}
@@ -1,15 +1,22 @@
package validation package validation
type Create struct { type Create struct {
Name string `json:"name" validate:"required_strict,min=3"` Name string `json:"name" validate:"required_strict,min=3"`
} }
type Update struct { type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"` Name *string `json:"name,omitempty" validate:"omitempty"`
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"`
BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"`
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"`
SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
} }
@@ -100,26 +100,24 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO {
} }
} }
func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO { func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO {
return AdjustmentRelationDTO{ return AdjustmentRelationDTO{
Id: e.Id, Id: e.Id,
Note: e.Notes, Note: "",
Increase: e.Increase, Increase: e.TotalQty,
Decrease: e.Decrease, Decrease: e.UsageQty,
ProductWarehouseId: e.ProductWarehouseId, ProductWarehouseId: e.ProductWarehouseId,
ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse),
} }
} }
func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO { func ToAdjustmentListDTO(e *entity.AdjustmentStock) AdjustmentListDTO {
var createdUser *userDTO.UserRelationDTO var createdUser *userDTO.UserRelationDTO
if e.CreatedUser != nil {
createdUser = &userDTO.UserRelationDTO{ // Get created user from StockLog
Id: e.CreatedUser.Id, if e.StockLog != nil && e.StockLog.CreatedUser != nil {
IdUser: e.CreatedUser.IdUser, mapped := userDTO.ToUserRelationDTO(*e.StockLog.CreatedUser)
Email: e.CreatedUser.Email, createdUser = &mapped
Name: e.CreatedUser.Name,
}
} }
return AdjustmentListDTO{ return AdjustmentListDTO{
@@ -129,9 +127,8 @@ func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO {
} }
} }
func ToAdjustmentDetailDTO(e *entity.StockLog) AdjustmentDetailDTO { func ToAdjustmentDetailDTO(e *entity.AdjustmentStock) AdjustmentDetailDTO {
return AdjustmentDetailDTO{ return AdjustmentDetailDTO{
AdjustmentListDTO: ToAdjustmentListDTO(e), AdjustmentListDTO: ToAdjustmentListDTO(e),
// UpdatedAt: e.UpdatedAt,
} }
} }
@@ -2,16 +2,21 @@ package repositories
import ( import (
"context" "context"
"fmt"
"strconv"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type AdjustmentStockRepository interface { type AdjustmentStockRepository interface {
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error
GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB DB() *gorm.DB
GenerateSequentialNumber(ctx context.Context, prefix string) (string, error)
} }
type adjustmentStockRepositoryImpl struct { type adjustmentStockRepositoryImpl struct {
@@ -30,11 +35,13 @@ func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *ent
return q.Create(data).Error return q.Create(data).Error
} }
func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error) {
var record entity.AdjustmentStock var record entity.AdjustmentStock
err := r.db.WithContext(ctx). q := r.db.WithContext(ctx)
Where("stock_log_id = ?", stockLogID). if modifier != nil {
First(&record).Error q = modifier(q)
}
err := q.First(&record, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -48,3 +55,71 @@ func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepos
func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB { func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB {
return r.db return r.db
} }
func (r *adjustmentStockRepositoryImpl) GenerateSequentialNumber(ctx context.Context, prefix string) (string, error) {
var values []string
err := r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where(fmt.Sprintf("%s ILIKE ?", "adj_number"), prefix+"%").
Select("adj_number").
Order(fmt.Sprintf("%s DESC", "adj_number")).
Limit(20).
Clauses(clause.Locking{Strength: "UPDATE"}).
Pluck("adj_number", &values).Error
if err != nil {
return "", err
}
next := 1
for _, value := range values {
if number, ok := parseNumericSuffix(value, prefix); ok {
next = number + 1
break
}
}
const maxAttempts = 20
for attempt := 0; attempt < maxAttempts; attempt++ {
candidate := fmt.Sprintf("%s%0*d", prefix, 5, next)
exists, err := r.numberExists(ctx, r.db, candidate)
if err != nil {
return "", err
}
if !exists {
return candidate, nil
}
next++
}
return "", fmt.Errorf("unable to generate unique %s", "adj_number")
}
func (r *adjustmentStockRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, value string) (bool, error) {
var count int64
if err := db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where(fmt.Sprintf("%s = ?", "adj_number"), value).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func parseNumericSuffix(value, prefix string) (int, bool) {
if !strings.HasPrefix(value, prefix) {
return 0, false
}
suffix := strings.TrimPrefix(value, prefix)
if suffix == "" {
return 0, false
}
trimmed := strings.TrimLeft(suffix, "0")
if trimmed == "" {
trimmed = "0"
}
number, err := strconv.Atoi(trimmed)
if err != nil {
return 0, false
}
return number, true
}
@@ -25,9 +25,9 @@ import (
) )
type AdjustmentService interface { type AdjustmentService interface {
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockLog, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error)
AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error)
} }
type adjustmentService struct { type adjustmentService struct {
@@ -70,13 +70,15 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProductWarehouse"). Preload("ProductWarehouse").
Preload("ProductWarehouse.Product"). Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse"). Preload("ProductWarehouse.Warehouse").
Preload("CreatedUser") Preload("StockLog.CreatedUser")
} }
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) { func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) {
stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
return s.withRelations(db).Preload("ProductWarehouse.Product.ProductCategory") return nil, err
}) }
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) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
@@ -85,14 +87,10 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err
return nil, err return nil, err
} }
if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { return adjustmentStock, nil
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
return stockLog, nil
} }
func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) { func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
@@ -101,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},
@@ -111,12 +112,13 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if req.Quantity <= 0 { if req.Quantity <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero")
} }
transactionType := strings.ToUpper(req.TransactionType) transactionType := strings.ToUpper(req.TransactionType)
if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type")
} }
var createdLogId uint var createdAdjustmentStockId uint
var projectFlockKandangID *uint var projectFlockKandangID *uint
pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID))
@@ -151,13 +153,13 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, err return nil, err
} }
err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID))
productWarehouse, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to get product warehouse: %+v", err) s.Log.Errorf("Failed to get product warehouse: %+v", err)
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,
@@ -166,31 +168,54 @@ 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 = afterQuantity newLog.Stock += newLog.Increase
} else { } else {
if productWarehouse.Quantity < req.Quantity { if productWarehouse.Quantity < req.Quantity {
return fiber.NewError(fiber.StatusBadRequest, "Insufficient stock for adjustment") 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 = afterQuantity 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 {
s.Log.Errorf("Failed to create stock log: %+v", err)
return err return err
} }
adjustmentStock := &entity.AdjustmentStock{ adjustmentStock := &entity.AdjustmentStock{
StockLogId: newLog.Id,
ProductWarehouseId: productWarehouse.Id, ProductWarehouseId: productWarehouse.Id,
} }
code, err := s.AdjustmentStockRepository.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix)
if err != nil {
return err
}
adjustmentStock.AdjNumber = code
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
s.Log.Errorf("Failed to create adjustment stock: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
} }
newLog.LoggableType = string(utils.StockLogTypeAdjustment)
newLog.LoggableId = adjustmentStock.Id
if err := s.StockLogsRepository.WithTx(tx).UpdateOne(ctx, newLog.Id, newLog, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link stock log")
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) { if transactionType == string(utils.StockLogTransactionTypeIncrease) {
note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id)
@@ -212,7 +237,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
UsableID: adjustmentStock.Id, UsableID: adjustmentStock.Id,
ProductWarehouseID: uint(productWarehouse.Id), ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity, Quantity: req.Quantity,
AllowPending: false, // Don't allow pending for adjustment AllowPending: false,
Tx: tx, Tx: tx,
}) })
if err != nil { if err != nil {
@@ -220,24 +245,20 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
} }
} }
// Update ProductWarehouse quantity (for backward compatibility/reporting) createdAdjustmentStockId = adjustmentStock.Id
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
}
createdLogId = newLog.Id
return nil return nil
}) })
if err != nil { if err != nil {
s.Log.Errorf("Transaction failed in CreateOne: %+v", err) s.Log.Errorf("Transaction failed in CreateOne: %+v", err)
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
return nil, err
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process adjustment transaction") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process adjustment transaction")
} }
return s.GetOne(c, createdLogId) return s.GetOne(c, createdAdjustmentStockId)
} }
func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
@@ -266,13 +287,15 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context,
return uint(projectFlockKandang.Id), nil return uint(projectFlockKandang.Id), nil
} }
func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) { func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) {
if err := s.Validate.Struct(query); err != nil { if err := s.Validate.Struct(query); err != nil {
return nil, 0, err return nil, 0, err
} }
offset := (query.Page - 1) * query.Limit offset := (query.Page - 1) * query.Limit
var isProductsExist bool
isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID))
if err != nil { if err != nil {
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse")
} }
@@ -280,7 +303,8 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
} }
isProductsExist, err := s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) isProductsExist, err = s.ProductRepo.IdExists(c.Context(), uint(query.ProductID))
if err != nil { if err != nil {
s.Log.Errorf("Failed to check product existence: %+v", err) s.Log.Errorf("Failed to check product existence: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product")
@@ -289,28 +313,58 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found")
} }
stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB { var adjustmentStocks []entity.AdjustmentStock
var total int64
db = s.withRelations(db) q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}).
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("StockLog.CreatedUser")
db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) scope, scopeErr := m.ResolveLocationScope(c, s.AdjustmentStockRepository.DB())
if scopeErr != nil {
if query.TransactionType != "" { return nil, 0, scopeErr
db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) }
if scope.Restrict {
if len(scope.IDs) == 0 {
return []*entity.AdjustmentStock{}, 0, nil
} }
db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID)) 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")
}
return db.Order("created_at DESC") if query.ProductID > 0 {
}) q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id").
Where("product_warehouses.product_id = ?", query.ProductID)
}
if query.WarehouseID > 0 {
q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id").
Where("product_warehouses.warehouse_id = ?", query.WarehouseID)
}
if query.TransactionType != "" {
q = q.Joins("JOIN stock_logs ON stock_logs.loggable_type = ? AND stock_logs.loggable_id = adjustment_stocks.id", "ADJUSTMENT").
Where("stock_logs.transaction_type = ?", strings.ToUpper(query.TransactionType))
}
if err = q.Count(&total).Error; err != nil {
s.Log.Errorf("Failed to get adjustments: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history")
}
err = q.Offset(offset).Limit(query.Limit).Order("created_at DESC").Find(&adjustmentStocks).Error
if err != nil { if err != nil {
s.Log.Errorf("Failed to get adjustments: %+v", err) s.Log.Errorf("Failed to get adjustments: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history")
} }
result := make([]*entity.StockLog, len(stockLogs)) result := make([]*entity.AdjustmentStock, len(adjustmentStocks))
for i, v := range stockLogs { for i := range adjustmentStocks {
result[i] = &v result[i] = &adjustmentStocks[i]
} }
return result, total, nil return result, total, nil
@@ -62,6 +62,7 @@ type StockLogDetailDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Increase float64 `json:"increase"` Increase float64 `json:"increase"`
Decrease float64 `json:"decrease"` Decrease float64 `json:"decrease"`
Stock float64 `json:"stock"`
LoggableType string `json:"loggable_type"` LoggableType string `json:"loggable_type"`
LoggableId uint `json:"loggable_id"` LoggableId uint `json:"loggable_id"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
@@ -195,6 +196,7 @@ func mapStockLogs(src []entity.StockLog) []StockLogDetailDTO {
Id: log.Id, Id: log.Id,
Increase: log.Increase, Increase: log.Increase,
Decrease: log.Decrease, Decrease: log.Decrease,
Stock: log.Stock,
LoggableType: log.LoggableType, LoggableType: log.LoggableType,
LoggableId: log.LoggableId, LoggableId: log.LoggableId,
Notes: notes, Notes: notes,
@@ -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")
} }
@@ -94,5 +175,47 @@ func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, err
s.Log.Errorf("Failed get product by id: %+v", err) s.Log.Errorf("Failed get product by id: %+v", err)
return nil, err return nil, err
} }
if len(product.ProductWarehouses) > 0 {
ids := make([]uint, 0, len(product.ProductWarehouses))
for _, pw := range product.ProductWarehouses {
if pw.Id != 0 {
ids = append(ids, pw.Id)
}
}
if len(ids) > 0 {
type pendingUsageRow struct {
ProductWarehouseId uint
PendingQty float64
}
var rows []pendingUsageRow
if err := s.ProductRepository.DB().WithContext(c.Context()).
Table("recording_stocks").
Select("product_warehouse_id, COALESCE(SUM(pending_qty), 0) AS pending_qty").
Where("pending_qty > 0").
Where("product_warehouse_id IN ?", ids).
Group("product_warehouse_id").
Scan(&rows).Error; err != nil {
s.Log.Errorf("Failed to load pending usage for product warehouses: %+v", err)
return nil, err
}
if len(rows) > 0 {
pendingMap := make(map[uint]float64, len(rows))
for _, row := range rows {
pendingMap[row.ProductWarehouseId] = row.PendingQty
}
for i := range product.ProductWarehouses {
pw := &product.ProductWarehouses[i]
if pending, ok := pendingMap[pw.Id]; ok && pending != 0 {
pw.Quantity -= pending
}
}
}
}
}
return product, nil return product, nil
} }
@@ -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,14 @@ 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, ""),
Type: c.Query("type", ""),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -5,6 +5,8 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
// === DTO Structs === // === DTO Structs ===
@@ -16,60 +18,30 @@ type ProductWarehouseRelationDTO struct {
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
} }
type ProductWarehousNestedDTO struct {
Id uint `json:"id"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
}
type ProductWarehouseListDTO struct { type ProductWarehouseListDTO struct {
ProductWarehouseRelationDTO ProductWarehouseRelationDTO
Product *productDTO.ProductRelationDTO `json:"product,omitempty"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"`
CreatedUser *UserRelationDTO `json:"created_user,omitempty"` Week int `json:"week"`
CreatedAt time.Time `json:"created_at"` CreatedUser *UserRelationDTO `json:"created_user,omitempty"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
} UpdatedAt time.Time `json:"updated_at"`
type UserRelationDTO struct {
Id uint `json:"id"`
Username string `json:"username"`
} }
type ProductWarehouseDetailDTO struct { type ProductWarehouseDetailDTO struct {
ProductWarehouseListDTO ProductWarehouseListDTO
} }
// Nested DTOs for relations type ProductWarehousNestedDTO struct {
type ProductRelationDTO struct { Id uint `json:"id"`
Id uint `json:"id"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Name string `json:"name"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
Sku string `json:"sku"`
Flags []string `json:"flags"`
} }
type WarehouseRelationDTO struct { type UserRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Username string `json:"username"`
Kandang *KandangRelationDTO `json:"kandang,omitempty"`
Location *LocationRelationDTO `json:"location,omitempty"`
Area *AreaRelationDTO `json:"area,omitempty"`
}
type KandangRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type LocationRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type AreaRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
} }
type ProjectFlockKandangRelationDTO struct { type ProjectFlockKandangRelationDTO struct {
@@ -96,65 +68,28 @@ func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRe
} }
} }
func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO {
product := productDTO.ToProductRelationDTO(e.Product)
return ProductWarehousNestedDTO{
Id: e.Id,
Product: &product,
Warehouse: &WarehouseRelationDTO{
Id: e.Warehouse.Id,
Name: e.Warehouse.Name,
},
}
}
func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO { func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO {
dto := ProductWarehouseListDTO{ dto := ProductWarehouseListDTO{
ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e), ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e),
// CreatedAt: e.CreatedAt,
// UpdatedAt: e.UpdatedAt,
} }
// Map Product relation jika ada // Map Product relation jika ada
if e.Product.Id != 0 { if e.Product.Id != 0 {
product := productDTO.ToProductRelationDTO(e.Product) product := productDTO.ToProductRelationDTO(e.Product)
// Tambahkan flock name ke product name jika ada project flock // Create a copy with flock name appended if exists
if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 {
product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" productCopy := product
productCopy.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")"
dto.Product = &productCopy
} else {
dto.Product = &product
} }
dto.Product = &product
} }
// Map Warehouse relation jika ada // Map Warehouse relation jika ada
if e.Warehouse.Id != 0 { if e.Warehouse.Id != 0 {
warehouse := WarehouseRelationDTO{ warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse)
Id: e.Warehouse.Id,
Name: e.Warehouse.Name,
}
// Map Kandang jika ada
if e.Warehouse.Kandang != nil && e.Warehouse.Kandang.Id != 0 {
warehouse.Kandang = &KandangRelationDTO{
Id: e.Warehouse.Kandang.Id,
Name: e.Warehouse.Kandang.Name,
}
}
// Map Location jika ada
if e.Warehouse.Location != nil && e.Warehouse.Location.Id != 0 {
warehouse.Location = &LocationRelationDTO{
Id: e.Warehouse.Location.Id,
Name: e.Warehouse.Location.Name,
}
}
if e.Warehouse.Area.Id != 0 {
warehouse.Area = &AreaRelationDTO{
Id: e.Warehouse.Area.Id,
Name: e.Warehouse.Area.Name,
}
}
dto.Warehouse = &warehouse dto.Warehouse = &warehouse
} }
@@ -168,7 +103,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
Period: e.ProjectFlockKandang.Period, Period: e.ProjectFlockKandang.Period,
} }
// Map ProjectFlock jika ada
if e.ProjectFlockKandang.ProjectFlock.Id != 0 { if e.ProjectFlockKandang.ProjectFlock.Id != 0 {
pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{
Id: e.ProjectFlockKandang.ProjectFlock.Id, Id: e.ProjectFlockKandang.ProjectFlock.Id,
@@ -177,16 +111,23 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
} }
dto.ProjectFlockKandang = pfkDTO dto.ProjectFlockKandang = pfkDTO
}
// Map CreatedUser relation jika ada // Calculate week for AYAM_PULLET/AYAM products
// if e.CreatedUser.Id != 0 { productFlags := make([]string, len(e.Product.Flags))
// user := UserRelationDTO{ for i, f := range e.Product.Flags {
// Id: e.CreatedUser.Id, productFlags[i] = f.Name
// Username: e.CreatedUser.Name, }
// }
// dto.CreatedUser = &user var category string
// } if e.ProjectFlockKandang.ProjectFlock.Id != 0 {
category = e.ProjectFlockKandang.ProjectFlock.Category
}
now := time.Now()
_, ageInWeeks := calculateAgeFromChickin(e.ProjectFlockKandang, &now, productFlags, category)
dto.Week = ageInWeeks
}
return dto return dto
} }
@@ -205,23 +146,68 @@ func ToProductWarehouseDetailDTO(e entity.ProductWarehouse) ProductWarehouseDeta
} }
} }
func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO { func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO {
return KandangRelationDTO{ product := productDTO.ToProductRelationDTO(e.Product)
Id: e.Id, warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse)
Name: e.Name,
return ProductWarehousNestedDTO{
Id: e.Id,
Product: &product,
Warehouse: &warehouse,
} }
} }
func ToLocationRelationDTO(e entity.Location) LocationRelationDTO { // Helper function to calculate age from chickin (same logic as closingMarketing.dto.go)
return LocationRelationDTO{ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, currentDate *time.Time, productFlags []string, category string) (int, int) {
Id: e.Id, if projectFlockKandang == nil || currentDate == nil || len(projectFlockKandang.Chickins) == 0 {
Name: e.Name, return 0, 0
} }
}
func ToAreaRelationDTO(e entity.Area) AreaRelationDTO { // Return 0 for TRADING, TELUR, and AYAM flags (only AYAM_PULLET should have week)
return AreaRelationDTO{ for _, flag := range productFlags {
Id: e.Id, if flag == string(utils.FlagOVK) ||
Name: e.Name, flag == string(utils.FlagPakan) ||
flag == string(utils.FlagPreStarter) ||
flag == string(utils.FlagStarter) ||
flag == string(utils.FlagFinisher) ||
flag == string(utils.FlagObat) ||
flag == string(utils.FlagVitamin) ||
flag == string(utils.FlagKimia) ||
flag == string(utils.FlagEkspedisi) ||
flag == string(utils.FlagTelur) ||
flag == string(utils.FlagTelurUtuh) ||
flag == string(utils.FlagTelurPecah) ||
flag == string(utils.FlagTelurPutih) ||
flag == string(utils.FlagTelurRetak) ||
flag == string(utils.FlagAyamAfkir) ||
flag == string(utils.FlagAyamCulling) ||
flag == string(utils.FlagAyamMati) {
return 0, 0
}
} }
// Find earliest chickin date
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
for _, chickin := range projectFlockKandang.Chickins {
if chickin.ChickInDate.Before(earliestChickinDate) {
earliestChickinDate = chickin.ChickInDate
}
}
diff := currentDate.Sub(earliestChickinDate)
ageInDays := int(diff.Hours() / 24)
var ageInWeeks int
if ageInDays <= 0 {
ageInWeeks = 0
} else {
if category == string(utils.ProjectFlockCategoryLaying) {
ageInDays = ageInDays + 119
ageInWeeks = ((ageInDays - 1) / 7) + 1
} else {
ageInWeeks = ((ageInDays - 1) / 7) + 1
}
}
return ageInDays, ageInWeeks
} }
@@ -168,9 +168,10 @@ func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []s
} }
return db. return db.
Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products"). Joins("JOIN flags f_flag ON f_flag.flagable_id = p_flag.id AND f_flag.flagable_type = ?", "products").
Where("flags.name IN ?", flags) Where("f_flag.name IN ?", flags).
Distinct()
} }
func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error { func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error {
@@ -3,15 +3,15 @@ package service
import ( import (
"errors" "errors"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"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"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -40,12 +40,14 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
return db. return db.
Preload("Product.Flags"). Preload("Product.Flags").
Preload("Product"). Preload("Product").
Preload("Product.Uom").
Preload("Warehouse"). Preload("Warehouse").
Preload("Warehouse.Location"). Preload("Warehouse.Location").
Preload("Warehouse.Area"). Preload("Warehouse.Area").
Preload("Warehouse.Kandang"). Preload("Warehouse.Kandang").
Preload("ProjectFlockKandang"). Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.ProjectFlock") Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.Chickins")
} }
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
@@ -53,6 +55,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 {
@@ -85,11 +100,32 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
var marketingTypes []string
if params.Type != "" {
marketingTypes = utils.ParseQueryArray(params.Type)
for _, t := range marketingTypes {
if !utils.IsValidMarketingType(t) {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type")
}
}
}
cleanFlags := utils.ParseFlags(params.Flags) cleanFlags := utils.ParseFlags(params.Flags)
productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
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)
} }
@@ -103,7 +139,48 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("warehouse_id = ?", params.WarehouseId) db = db.Where("warehouse_id = ?", params.WarehouseId)
} }
db = s.Repository.ApplyFlagsFilter(db, cleanFlags) if len(marketingTypes) > 0 {
flagSet := make(map[string]struct{})
for _, t := range marketingTypes {
switch t {
case string(utils.MarketingTypeAyamPullet):
flagSet[string(utils.FlagDOC)] = struct{}{}
flagSet[string(utils.FlagPullet)] = struct{}{}
flagSet[string(utils.FlagLayer)] = struct{}{}
case string(utils.MarketingTypeAyam):
flagSet[string(utils.FlagAyamAfkir)] = struct{}{}
flagSet[string(utils.FlagAyamCulling)] = struct{}{}
flagSet[string(utils.FlagAyamMati)] = struct{}{}
case string(utils.MarketingTypeTelur):
flagSet[string(utils.FlagTelur)] = struct{}{}
flagSet[string(utils.FlagTelurUtuh)] = struct{}{}
flagSet[string(utils.FlagTelurPecah)] = struct{}{}
flagSet[string(utils.FlagTelurPutih)] = struct{}{}
flagSet[string(utils.FlagTelurRetak)] = struct{}{}
case string(utils.MarketingTypeTrading):
flagSet[string(utils.FlagPakan)] = struct{}{}
flagSet[string(utils.FlagPreStarter)] = struct{}{}
flagSet[string(utils.FlagStarter)] = struct{}{}
flagSet[string(utils.FlagFinisher)] = struct{}{}
flagSet[string(utils.FlagOVK)] = struct{}{}
flagSet[string(utils.FlagObat)] = struct{}{}
flagSet[string(utils.FlagVitamin)] = struct{}{}
flagSet[string(utils.FlagKimia)] = struct{}{}
flagSet[string(utils.FlagEkspedisi)] = struct{}{}
}
}
if len(flagSet) > 0 {
flags := make([]string, 0, len(flagSet))
for f := range flagSet {
flags = append(flags, f)
}
db = s.Repository.ApplyFlagsFilter(db, flags)
}
}
if len(cleanFlags) > 0 {
db = s.Repository.ApplyFlagsFilter(db, cleanFlags)
}
return db.Order("product_warehouses.id DESC") return db.Order("product_warehouses.id DESC")
}) })
@@ -116,7 +193,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,12 @@ 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"`
Type string `query:"type" validate:"omitempty"`
} }
@@ -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,
@@ -40,6 +40,6 @@ func (r *StockTransferRepositoryImpl) GenerateMovementNumber(ctx context.Context
if err != nil { if err != nil {
return "", err return "", err
} }
movementNumber := fmt.Sprintf("ST-%05d", seq) movementNumber := fmt.Sprintf("PND-LTI-%05d", seq)
return movementNumber, nil return movementNumber, nil
} }
@@ -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)
@@ -124,7 +160,8 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id)) return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
} }
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data transfer dengan ID %d", id)) s.Log.Errorf("Failed to fetch transfer by ID %d: %+v", id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
} }
return transferPtr, nil return transferPtr, nil
@@ -142,7 +179,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
} }
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengecek stok produk %d di gudang asal", product.ProductID)) s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
} }
if sourcePW.Quantity < product.ProductQty { if sourcePW.Quantity < product.ProductQty {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty)) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty))
@@ -163,12 +201,15 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, err return nil, err
} }
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) if destPfkID > 0 {
if err != nil { projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock untuk gudang tujuan") if err != nil {
} s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err)
if projectFlockKandang.ClosedAt != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02"))) }
if projectFlockKandang.ClosedAt != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02")))
}
} }
actorID, err := m.ActorIDFromContext(c) actorID, err := m.ActorIDFromContext(c)
@@ -191,12 +232,31 @@ 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) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID)) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID))
} }
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data supplier dengan ID %d", delivery.SupplierID)) s.Log.Errorf("Failed to fetch supplier by ID %d: %+v", delivery.SupplierID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data supplier")
} }
if supplier.Category != string(utils.SupplierCategoryBOP) { if supplier.Category != string(utils.SupplierCategoryBOP) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier '%s' (ID: %d) bukan kategori BOP. Kategori saat ini: %s", supplier.Name, delivery.SupplierID, supplier.Category)) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier '%s' (ID: %d) bukan kategori BOP. Kategori saat ini: %s", supplier.Name, delivery.SupplierID, supplier.Category))
@@ -205,7 +265,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context()) movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context())
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor movement transfer") s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
} }
transferDate, _ := utils.ParseDateString(req.TransferDate) transferDate, _ := utils.ParseDateString(req.TransferDate)
@@ -228,6 +289,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx) stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx)
stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx) stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx)
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx) productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
stocklogsRepoTx := s.StockLogsRepository.WithTx(tx)
if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil { if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil {
return err return err
@@ -245,14 +307,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
} }
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang asal", product.ProductID)) s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
} }
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
) )
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang tujuan", product.ProductID)) s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
} }
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context() ctx := c.Context()
@@ -261,7 +325,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return err return err
} }
// Set ProjectFlockKandangId hanya jika ada kandang
var pfkID *uint var pfkID *uint
if projectFlockKandangID > 0 { if projectFlockKandangID > 0 {
pfkID = &projectFlockKandangID pfkID = &projectFlockKandangID
@@ -274,7 +337,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
ProjectFlockKandangId: pfkID, ProjectFlockKandangId: pfkID,
} }
if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal membuat product warehouse untuk produk %d di gudang tujuan", product.ProductID)) s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
} }
} }
@@ -300,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,
@@ -364,9 +435,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Files: documentFiles, Files: documentFiles,
}) })
if err != nil { if err != nil {
s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)", s.Log.Errorf("Failed to upload document for delivery %d (delivery_id=%d, filename=%s): %+v",
deliveryIdx+1, delivery.Id, file.Filename) deliveryIdx+1, delivery.Id, file.Filename, err)
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", deliveryIdx+1, err)) return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengunggah dokumen")
} }
} }
} }
@@ -392,7 +463,33 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
"usage_qty": consumeResult.UsageQuantity, "usage_qty": consumeResult.UsageQuantity,
"pending_qty": consumeResult.PendingQuantity, "pending_qty": consumeResult.PendingQuantity,
}).Error; err != nil { }).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking usage untuk produk %d", product.ProductID)) s.Log.Errorf("Failed to update tracking usage for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: uint(actorID),
Increase: 0,
Decrease: product.ProductQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
}
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
} }
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
@@ -405,7 +502,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Tx: tx, Tx: tx,
}) })
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok untuk produk %d di gudang tujuan. Error: %v", product.ProductID, err)) s.Log.Errorf("Failed to replenish stock for product_id=%d, pw_id=%d, qty=%.2f: %+v", product.ProductID, *detail.DestProductWarehouseID, product.ProductQty, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menambah stok gudang tujuan")
} }
if err := tx.Model(&entity.StockTransferDetail{}). if err := tx.Model(&entity.StockTransferDetail{}).
@@ -413,12 +511,42 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"total_qty": replenishResult.AddedQuantity, "total_qty": replenishResult.AddedQuantity,
}).Error; err != nil { }).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking total untuk produk %d", product.ProductID)) s.Log.Errorf("Failed to update tracking total for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.DestProductWarehouseID),
CreatedBy: uint(actorID),
Increase: product.ProductQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
}
stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
} }
} }
if len(req.Deliveries) > 0 { 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 {
@@ -447,7 +575,10 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}) })
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal memproses transfer. Error: %v", err)) if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
} }
result, err := s.GetOne(c, uint(entityTransfer.Id)) result, err := s.GetOne(c, uint(entityTransfer.Id))
@@ -457,7 +588,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if len(expensePayloads) > 0 { if len(expensePayloads) > 0 {
if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil { if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal sinkronisasi data expense untuk transfer %s. Silakan cek manual di module expense", entityTransfer.MovementNumber)) s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", entityTransfer.Id, entityTransfer.MovementNumber, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense")
} }
} }
@@ -477,10 +609,10 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID))
} }
return 0, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data gudang dengan ID %d", warehouseID)) s.Log.Errorf("Failed to fetch warehouse by ID %d: %+v", warehouseID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang")
} }
// Jika warehouse tidak punya kandang_id, return 0 tanpa error
if warehouse.KandangId == nil || *warehouse.KandangId == 0 { if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
return 0, nil return 0, nil
} }
@@ -490,7 +622,8 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak ada project flock aktif untuk kandang %d", *warehouse.KandangId)) return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak ada project flock aktif untuk kandang %d", *warehouse.KandangId))
} }
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock kandang yang aktif") s.Log.Errorf("Failed to fetch active project flock kandang for kandang_id=%d: %+v", *warehouse.KandangId, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
} }
return uint(projectFlockKandang.Id), nil return uint(projectFlockKandang.Id), 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"`
} }
@@ -3,6 +3,7 @@ package controller
import ( import (
"math" "math"
"strconv" "strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services"
@@ -23,9 +24,38 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer
} }
func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
parseUintListParam := func(param string) ([]uint, error) {
if param == "" {
return nil, nil
}
parts := strings.Split(param, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
ids = append(ids, uint(parsed))
}
return ids, nil
}
productIDs, err := parseUintListParam(c.Query("product_ids", ""))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
}
query := &validation.DeliveryOrderQuery{ query := &validation.DeliveryOrderQuery{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search", "")),
ProductIDs: productIDs,
Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "),
CustomerId: uint(c.QueryInt("customer_id", 0)),
MarketingId: uint(c.QueryInt("marketing_id", 0)), MarketingId: uint(c.QueryInt("marketing_id", 0)),
} }
@@ -2,19 +2,23 @@ package dto
import ( import (
"fmt" "fmt"
"math"
"sort" "sort"
"strings"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto" productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
) )
type MarketingRelationDTO struct { type MarketingRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
SoNumber string `json:"so_number"` SoNumber string `json:"so_number"`
DoNumber *string `json:"do_number"`
SoDate time.Time `json:"so_date"` SoDate time.Time `json:"so_date"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
} }
@@ -68,52 +72,76 @@ type DeliveryItemDTO struct {
} }
type DeliveryGroupDTO struct { type DeliveryGroupDTO struct {
DoNumber string `json:"do_number"` DoNumber string `json:"do_number"`
DeliveryDate *time.Time `json:"delivery_date"` DeliveryDate *time.Time `json:"delivery_date"`
Warehouse *productwarehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
Deliveries []DeliveryItemDTO `json:"deliveries"` Deliveries []DeliveryItemDTO `json:"deliveries"`
} }
type DeliveryMarketingProductDTO struct { type DeliveryMarketingProductDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
MarketingId uint `json:"marketing_id"` MarketingId uint `json:"marketing_id"`
ProductWarehouseId uint `json:"product_warehouse_id"` ProductWarehouseId uint `json:"product_warehouse_id"`
Qty float64 `json:"qty"` MarketingType string `json:"marketing_type"`
UnitPrice float64 `json:"unit_price"` Qty float64 `json:"qty"`
AvgWeight float64 `json:"avg_weight"` UnitPrice float64 `json:"unit_price"`
TotalWeight float64 `json:"total_weight"` AvgWeight float64 `json:"avg_weight"`
TotalPrice float64 `json:"total_price"` TotalWeight float64 `json:"total_weight"`
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` TotalPrice float64 `json:"total_price"`
VehicleNumber string `json:"vehicle_number,omitempty"` ConvertionUnit *string `json:"convertion_unit,omitempty"`
WeightPerConvertion *float64 `json:"weight_per_convertion,omitempty"`
TotalPeti *float64 `json:"total_peti,omitempty"`
Week *int `json:"week,omitempty"`
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
VehicleNumber string `json:"vehicle_number,omitempty"`
} }
func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO { func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO {
var doNumber *string
if doNumbers := collectDoNumbers(marketing); len(doNumbers) > 0 {
value := doNumbers[0]
doNumber = &value
}
return MarketingRelationDTO{ return MarketingRelationDTO{
Id: marketing.Id, Id: marketing.Id,
SoNumber: marketing.SoNumber, SoNumber: marketing.SoNumber,
DoNumber: doNumber,
SoDate: marketing.SoDate, SoDate: marketing.SoDate,
Notes: marketing.Notes, Notes: marketing.Notes,
} }
} }
func ToDeliveryMarketingProductDTO(e entity.MarketingProduct) DeliveryMarketingProductDTO { func ToDeliveryMarketingProductDTO(e entity.MarketingProduct, marketingType string) DeliveryMarketingProductDTO {
var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO
if e.ProductWarehouse.Id != 0 { if e.ProductWarehouse.Id != 0 {
mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse) mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse)
productWarehouse = &mapped productWarehouse = &mapped
} }
// Calculate total_peti only for TELUR marketing type
var totalPeti *float64
if marketingType == "TELUR" && e.ConvertionUnit != nil && *e.ConvertionUnit == "PETI" && e.WeightPerConvertion != nil && *e.WeightPerConvertion > 0 {
calculated := math.Floor(e.TotalWeight / *e.WeightPerConvertion)
totalPeti = &calculated
}
return DeliveryMarketingProductDTO{ return DeliveryMarketingProductDTO{
Id: e.Id, Id: e.Id,
MarketingId: e.MarketingId, MarketingId: e.MarketingId,
ProductWarehouseId: e.ProductWarehouseId, ProductWarehouseId: e.ProductWarehouseId,
Qty: e.Qty, MarketingType: marketingType,
UnitPrice: e.UnitPrice, Qty: e.Qty,
AvgWeight: e.AvgWeight, UnitPrice: e.UnitPrice,
TotalWeight: e.TotalWeight, AvgWeight: e.AvgWeight,
TotalPrice: e.TotalPrice, TotalWeight: e.TotalWeight,
ProductWarehouse: productWarehouse, TotalPrice: e.TotalPrice,
VehicleNumber: getVehicleNumber(e), ConvertionUnit: e.ConvertionUnit,
WeightPerConvertion: e.WeightPerConvertion,
TotalPeti: totalPeti,
Week: e.Week,
ProductWarehouse: productWarehouse,
VehicleNumber: getVehicleNumber(e),
} }
} }
@@ -160,10 +188,9 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M
if len(marketing.Products) > 0 { if len(marketing.Products) > 0 {
salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products))
for i, product := range marketing.Products { for i, product := range marketing.Products {
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType)
} }
} }
return MarketingListDTO{ return MarketingListDTO{
MarketingRelationDTO: ToMarketingRelationDTO(marketing), MarketingRelationDTO: ToMarketingRelationDTO(marketing),
Customer: customer, Customer: customer,
@@ -200,7 +227,7 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity
if len(marketing.Products) > 0 { if len(marketing.Products) > 0 {
salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products))
for i, product := range marketing.Products { for i, product := range marketing.Products {
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType)
} }
} }
@@ -220,7 +247,6 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity
mapped := approvalDTO.ToApprovalDTO(*marketing.LatestApproval) mapped := approvalDTO.ToApprovalDTO(*marketing.LatestApproval)
latestApproval = mapped latestApproval = mapped
} }
return MarketingDetailDTO{ return MarketingDetailDTO{
MarketingRelationDTO: ToMarketingRelationDTO(marketing), MarketingRelationDTO: ToMarketingRelationDTO(marketing),
SoDocs: marketing.SoDocs, SoDocs: marketing.SoDocs,
@@ -286,7 +312,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
if !exists { if !exists {
group = &DeliveryGroupDTO{ group = &DeliveryGroupDTO{
DeliveryDate: product.DeliveryDate, DeliveryDate: product.DeliveryDate,
Warehouse: &productwarehouseDTO.WarehouseRelationDTO{ Warehouse: &warehouseDTO.WarehouseRelationDTO{
Id: warehouseId, Id: warehouseId,
Name: warehouseName, Name: warehouseName,
}, },
@@ -327,11 +353,46 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
} }
func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, warehouseId uint) string { func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, warehouseId uint) string {
dateStr := "" numberPrefix := soNumber
if deliveryDate != nil { if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(soNumber)), "SO-") {
dateStr = deliveryDate.Format("20060102") numberPrefix = "DO-" + soNumber[3:]
} }
return fmt.Sprintf("%s-%s-%d", soNumber, dateStr, warehouseId) return numberPrefix
}
func collectDoNumbers(marketing *entity.Marketing) []string {
if marketing == nil || len(marketing.Products) == 0 {
return nil
}
seen := make(map[string]struct{})
for _, product := range marketing.Products {
if product.DeliveryProduct == nil || product.DeliveryProduct.DeliveryDate == nil {
continue
}
warehouseID := product.ProductWarehouse.WarehouseId
if warehouseID == 0 && product.ProductWarehouse.Warehouse.Id != 0 {
warehouseID = product.ProductWarehouse.Warehouse.Id
}
if warehouseID == 0 {
continue
}
doNumber := GenerateDeliveryOrderNumber(marketing.SoNumber, product.DeliveryProduct.DeliveryDate, warehouseID)
if doNumber != "" {
seen[doNumber] = struct{}{}
}
}
if len(seen) == 0 {
return nil
}
result := make([]string, 0, len(seen))
for value := range seen {
result = append(result, value)
}
sort.Strings(result)
return result
} }
func getVehicleNumber(e entity.MarketingProduct) string { func getVehicleNumber(e entity.MarketingProduct) string {
@@ -1,6 +1,7 @@
package dto package dto
import ( import (
"math"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -10,13 +11,18 @@ import (
// === DTO Structs === // === DTO Structs ===
type MarketingProductDTO struct { type MarketingProductDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Qty float64 `json:"qty"` MarketingType string `json:"marketing_type"`
UnitPrice float64 `json:"unit_price"` Qty float64 `json:"qty"`
AvgWeight float64 `json:"avg_weight"` UnitPrice float64 `json:"unit_price"`
TotalWeight float64 `json:"total_weight"` AvgWeight float64 `json:"avg_weight"`
TotalPrice float64 `json:"total_price"` TotalWeight float64 `json:"total_weight"`
ProductWarehouse *productWarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` TotalPrice float64 `json:"total_price"`
ConvertionUnit *string `json:"convertion_unit,omitempty"`
WeightPerConvertion *float64 `json:"weight_per_convertion,omitempty"`
TotalPeti *float64 `json:"total_peti,omitempty"`
Week *int `json:"week,omitempty"`
ProductWarehouse *productWarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
} }
type SalesOrdersListDTO struct { type SalesOrdersListDTO struct {
@@ -29,7 +35,7 @@ type SalesOrdersListDTO struct {
// === Mapper Functions === // === Mapper Functions ===
func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO { func ToMarketingProductDTO(e entity.MarketingProduct, marketingType string) MarketingProductDTO {
var productWarehouse *productWarehouseDTO.ProductWarehousNestedDTO var productWarehouse *productWarehouseDTO.ProductWarehousNestedDTO
if e.ProductWarehouse.Id != 0 { if e.ProductWarehouse.Id != 0 {
@@ -37,21 +43,33 @@ func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO {
productWarehouse = &mapped productWarehouse = &mapped
} }
// Calculate total_peti only for TELUR marketing type
var totalPeti *float64
if marketingType == "TELUR" && e.ConvertionUnit != nil && *e.ConvertionUnit == "PETI" && e.WeightPerConvertion != nil && *e.WeightPerConvertion > 0 {
calculated := math.Floor(e.TotalWeight / *e.WeightPerConvertion)
totalPeti = &calculated
}
return MarketingProductDTO{ return MarketingProductDTO{
Id: e.Id, Id: e.Id,
Qty: e.Qty, MarketingType: marketingType,
UnitPrice: e.UnitPrice, Qty: e.Qty,
AvgWeight: e.AvgWeight, UnitPrice: e.UnitPrice,
TotalWeight: e.TotalWeight, AvgWeight: e.AvgWeight,
TotalPrice: e.TotalPrice, TotalWeight: e.TotalWeight,
ProductWarehouse: productWarehouse, TotalPrice: e.TotalPrice,
ConvertionUnit: e.ConvertionUnit,
WeightPerConvertion: e.WeightPerConvertion,
TotalPeti: totalPeti,
Week: e.Week,
ProductWarehouse: productWarehouse,
} }
} }
func ToSalesOrdersListDTO(e entity.Marketing) SalesOrdersListDTO { func ToSalesOrdersListDTO(e entity.Marketing) SalesOrdersListDTO {
products := make([]MarketingProductDTO, len(e.Products)) products := make([]MarketingProductDTO, len(e.Products))
for i, p := range e.Products { for i, p := range e.Products {
products[i] = ToMarketingProductDTO(p) products[i] = ToMarketingProductDTO(p, e.MarketingType)
} }
return SalesOrdersListDTO{ return SalesOrdersListDTO{
@@ -68,7 +86,7 @@ func ToSalesOrdersListDTOFromMarketing(e entity.Marketing) SalesOrdersListDTO {
if len(e.Products) > 0 { if len(e.Products) > 0 {
salesOrder = make([]MarketingProductDTO, len(e.Products)) salesOrder = make([]MarketingProductDTO, len(e.Products))
for i, product := range e.Products { for i, product := range e.Products {
salesOrder[i] = ToMarketingProductDTO(product) salesOrder[i] = ToMarketingProductDTO(product, e.MarketingType)
} }
} }

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