Compare commits

..

190 Commits

Author SHA1 Message Date
ragilap 4c08fadb7a fix length type varchar for purchase vehicle_number 2026-04-14 14:46:55 +07:00
Adnan Zahir cae7f3ef63 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!414
2026-04-14 12:01:04 +07:00
Adnan Zahir fbe0634d46 Merge branch 'feat/customer' into 'development'
[FEAT][BE]: add query param get customer has marketing

See merge request mbugroup/lti-api!413
2026-04-13 16:34:29 +07:00
giovanni bca02800d6 add query param get customer has marketing 2026-04-13 14:47:00 +07:00
Adnan Zahir 42793d94bd Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!412
2026-04-13 14:12:48 +07:00
Adnan Zahir 45e430f01d Merge branch 'feat/kandang-periode' into 'development'
[FEAT][BE]: adjust api get all project flock kandang with periode

See merge request mbugroup/lti-api!409
2026-04-13 12:58:23 +07:00
giovanni cff5837ff9 adjust default order dan sort by 2026-04-13 12:14:54 +07:00
giovanni 5e2187c46b adjust default order by dan sort by 2026-04-13 11:49:41 +07:00
giovanni d1612e5c65 add query param location id 2026-04-13 10:51:12 +07:00
giovanni 30a47ffc71 Merge branch 'development' into feat/kandang-periode 2026-04-13 10:30:56 +07:00
giovanni b79738dbe1 fix calculate egg mass and hen house recordings 2026-04-13 10:30:39 +07:00
giovanni 3eb225cca8 adjust validation from week 19 2026-04-13 10:30:39 +07:00
Adnan Zahir 1369bf0e36 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!411
2026-04-11 14:08:35 +07:00
Adnan Zahir a9a84539eb Merge branch 'fix/recording' into 'development'
[FIX][BE]: fix calculate egg mass and hen house recordings

See merge request mbugroup/lti-api!410
2026-04-11 13:56:32 +07:00
giovanni 3702d41954 fix calculate egg mass and hen house recordings 2026-04-10 17:16:28 +07:00
Adnan Zahir ffd96105ce Merge branch 'fix/uniformity' into 'development'
[FIX][BE]: adjust validation from week 19

See merge request mbugroup/lti-api!408
2026-04-10 15:21:23 +07:00
giovanni 3d75251c96 adjust api get all project flock kandang with periode 2026-04-10 14:09:31 +07:00
Adnan Zahir 361d14bd3e Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!406
2026-04-10 10:50:07 +07:00
giovanni 7c848bc50d adjust validation from week 19 2026-04-09 17:00:03 +07:00
Adnan Zahir ddcf13e2ff Merge branch 'feat/dashboard-uniformity' into 'development'
[FEAT][BE]: adjust dashboard uniformity and validation add uniformity

See merge request mbugroup/lti-api!407
2026-04-09 15:36:44 +07:00
giovanni e8c33f818b adjust dashboard uniformity and validation add uniformity 2026-04-09 15:28:26 +07:00
Adnan Zahir 3daed7e248 Merge branch 'feat/export-recording' into 'development'
[FEAT][BE]: add export excel to get all recording

See merge request mbugroup/lti-api!405
2026-04-09 13:23:36 +07:00
Adnan Zahir cde4647b15 Merge branch 'fix/edit-receipt' into 'development'
[FIX][BE]: fix edit receipt purchase

See merge request mbugroup/lti-api!404
2026-04-09 13:12:16 +07:00
giovanni abc0ac8258 add export excel to get all recording 2026-04-09 11:18:05 +07:00
Adnan Zahir aad4f7dc28 Merge branch 'fix/filter-purchase-order' into 'development'
Fix/filter purchase order

See merge request mbugroup/lti-api!403
2026-04-08 17:01:54 +07:00
Adnan Zahir bfe7b5129f Merge branch 'fix/marketing-delivery' into 'development'
[FIX][BE]: fix get detail marketing delivery

See merge request mbugroup/lti-api!402
2026-04-08 16:54:53 +07:00
giovanni a6995f8e18 fix edit receipt purchase 2026-04-08 16:18:55 +07:00
giovanni 450d1e8cee add filter lokasi and bop to purchase order 2026-04-08 14:24:04 +07:00
ragilap b58e9a10b1 fix filter purchase supplier repport 2026-04-08 14:13:19 +07:00
ragilap aa9863646e fix filter purchase ?approval_status=approved,rejected and ?product_category_id=1,2,3 2026-04-08 14:12:18 +07:00
ragilap 2a3154042c fix filter purchase query param and search 2026-04-08 14:07:40 +07:00
giovanni 80f190b69b fix get detail marketing delivery 2026-04-08 13:41:17 +07:00
Adnan Zahir 7923352535 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!401
2026-04-07 22:58:38 +07:00
Adnan Zahir 079ae01b94 Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: adjust calculate total price create sales order for telur and convertion peti and qty

See merge request mbugroup/lti-api!400
2026-04-07 22:44:49 +07:00
Adnan Zahir ee7fa71139 codex/command: migrate egg stocks from kandang to farm (adjustment_stocks only) 2026-04-07 21:54:51 +07:00
Adnan Zahir f2827d5352 codex/command: migrate egg stocks from kandang to farm 2026-04-07 20:28:05 +07:00
giovanni 18cf180982 adjust calculate total price create sales order for telur and convertion peti and qty 2026-04-07 18:39:32 +07:00
Adnan Zahir 010240066a Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!399
2026-04-07 16:52:45 +07:00
giovanni fc0b45b433 remove migration change type data qty recording eggs 2026-04-07 15:48:59 +07:00
giovanni 4d85d6f320 add migration change data type field qty di table recording eggs 2026-04-07 15:35:14 +07:00
Adnan Zahir e3cfb2648b Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: Codex/sales at farm level

See merge request mbugroup/lti-api!397
2026-04-07 14:43:49 +07:00
giovanni ba8b512293 add cmd for reflow quantity product warehouse from stock allocation 2026-04-07 14:33:38 +07:00
giovanni 7bd9ec9ef8 adjust level 2 cmd adjust quantity product warehouse from purchase 2026-04-07 13:58:01 +07:00
Adnan Zahir 037f9fc71b Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: add command for adjust data quantity product warehouse from purchase items

See merge request mbugroup/lti-api!396
2026-04-07 12:42:21 +07:00
giovanni 8fa41e379d add command for adjust data quantity product warehouse from purchase items 2026-04-07 12:00:02 +07:00
Adnan Zahir 7d9c752432 Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: adjust response detail recording

See merge request mbugroup/lti-api!395
2026-04-07 11:04:20 +07:00
giovanni 6342a28f09 adjust response detail recording 2026-04-07 10:59:59 +07:00
Adnan Zahir 945b6aba0a Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: adjust get data product suppliers

See merge request mbugroup/lti-api!394
2026-04-06 23:41:24 +07:00
giovanni 23e49a00e4 adjust get data product suppliers 2026-04-06 23:38:27 +07:00
Adnan Zahir 480c899f6a Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: hidden product warehouse depletion and egg <= 0

See merge request mbugroup/lti-api!393
2026-04-06 22:32:51 +07:00
Adnan Zahir ba4a5324ed codex/fix: hidden product warehouse depletion and egg <= 0 2026-04-06 22:27:58 +07:00
Adnan Zahir 4899cee98f Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: store stocks on farm warehouse when recording egg

See merge request mbugroup/lti-api!392
2026-04-04 11:22:31 +07:00
Adnan Zahir 2a39342d55 codex/fix: store stocks on farm warehouse when recording egg 2026-04-04 11:21:50 +07:00
Adnan Zahir f29f09d7b9 Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: inconsistent stock options and availability

See merge request mbugroup/lti-api!391
2026-04-04 10:08:24 +07:00
Adnan Zahir 4254cbf576 Merge branch 'production' into 'development'
Production (Back Merge after Hotfixes)

See merge request mbugroup/lti-api!390
2026-04-04 09:55:16 +07:00
Adnan Zahir 34a3fc44a8 codex/fix: inconsistent stock options and availability 2026-04-04 09:52:59 +07:00
Adnan Zahir fdfc5e069d Merge branch 'hot-fix/daily-checklist' into 'production'
[FIX][BE]: fix upser daily checklist status rejected; fix search list daily checklist

See merge request mbugroup/lti-api!389
2026-04-02 15:29:34 +07:00
giovanni 6880010424 fix upser daily checklist status rejected; fix search list daily checklist 2026-04-02 14:53:59 +07:00
Adnan Zahir 9cc9146641 Merge branch 'hot-fix/filter-purchase' into 'production'
fix filter purchase supplier repport

See merge request mbugroup/lti-api!388
2026-04-02 14:35:06 +07:00
ragilap 8be4b54127 fix filter purchase supplier repport 2026-04-02 11:43:44 +07:00
Adnan Zahir 9ce69ddeb0 Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: qty 0 after PO to farm-level warehouse

See merge request mbugroup/lti-api!385
2026-04-01 16:19:45 +07:00
Adnan Zahir 7b4bf94329 codex/fix: qty 0 after PO to farm-level warehouse 2026-04-01 16:14:07 +07:00
Adnan Zahir 524dc385ff Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: show farm stock usage on closing page

See merge request mbugroup/lti-api!384
2026-04-01 12:33:09 +07:00
Adnan Zahir 5ffb72507b codex/fix: show farm stock usage on closing page 2026-04-01 12:31:04 +07:00
Adnan Zahir 0bf9844efc Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: recheck and fix purchase receive failed and farm stock not shown on recording

See merge request mbugroup/lti-api!383
2026-04-01 11:48:45 +07:00
Adnan Zahir c4add1501d codex/fix: recheck and fix purchase receive failed and farm stock not shown on recording 2026-04-01 11:46:44 +07:00
Adnan Zahir f81e2f7c01 Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: purchase receivement error and recording doesn't show depletion/egg

See merge request mbugroup/lti-api!382
2026-04-01 11:10:19 +07:00
Adnan Zahir 030284a9b5 codex/fix: purchase receivement error and recording doesn't show depletion/egg 2026-04-01 11:03:35 +07:00
Adnan Zahir a55aa873a6 Merge branch 'codex/sales-at-farm-level' into 'development'
codex: initiated changes (farm-level warehouse stock manipulation on recording and sales)

See merge request mbugroup/lti-api!381
2026-04-01 10:18:45 +07:00
Adnan Zahir be00837148 codex: initiated changes 2026-03-30 13:40:29 +07:00
Adnan Zahir 434ae2f246 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!380
2026-03-27 15:39:51 +07:00
Adnan Zahir b6f369a5ec Merge branch 'fix/kandang-groups' into 'development'
[FIX][BE]: add restrict delete master data kandang group

See merge request mbugroup/lti-api!379
2026-03-27 14:39:49 +07:00
giovanni 63cf0c6fac add restrict delete master data kandang group 2026-03-27 12:11:17 +07:00
Adnan Zahir c48f0411d3 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!378
2026-03-25 12:47:21 +07:00
Adnan Zahir 6510bccc76 Merge branch 'fix/adjustment-population-chickin' into 'development'
fix asof chickin adjustment

See merge request mbugroup/lti-api!377
2026-03-25 12:43:16 +07:00
ragilap d226d5f7f3 fix asof chickin adjustment 2026-03-25 12:42:05 +07:00
Adnan Zahir f40c643876 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!376
2026-03-18 00:13:22 +07:00
Adnan Zahir 325825a709 Merge branch 'add/cut-over-logic-recording-response' into 'development'
add response logic cut over recording for view

See merge request mbugroup/lti-api!375
2026-03-17 23:55:48 +07:00
ragilap 3b3ee8b796 add response logic cut over recording for view 2026-03-17 23:50:58 +07:00
Adnan Zahir 18cb116a51 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!374
2026-03-17 18:52:44 +07:00
Adnan Zahir bf4aa5ccea Merge branch 'fix/is-transfer-for-cut-over' into 'development'
fix: override is_transfer lookup for direct cut-over laying flocks

See merge request mbugroup/lti-api!373
2026-03-17 18:44:11 +07:00
Adnan Zahir 2713210bcc fix: override is_transfer lookup for direct cut-over laying flocks 2026-03-17 18:41:51 +07:00
M1 AIR 1ab1909998 Merge branch 'development' into production 2026-03-17 14:46:09 +07:00
M1 AIR d76f72050e fix(migrations): restore missing project chickin bootstrap steps 2026-03-17 14:41:23 +07:00
Adnan Zahir 41c910677f Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!372
2026-03-17 13:35:13 +07:00
Adnan Zahir 5d1eb60fb2 Merge branch 'dev/fifo-v2' into 'development'
Implement delete transfer to laying, chickin, and stock adjustment

See merge request mbugroup/lti-api!371
2026-03-17 11:07:52 +07:00
Adnan Zahir c4c414aa94 Merge branch 'feat/BE/implement-new-trf' into 'dev/fifo-v2'
Fix transfer to laying delete and fix chikin delete with response recording

See merge request mbugroup/lti-api!366
2026-03-17 11:04:04 +07:00
ragilap c9dee7d1c4 add paired adjustment triger depletion adjustment 2026-03-17 11:02:37 +07:00
ragilap 131949874a changes name response transfer 2026-03-16 11:08:37 +07:00
ragilap d0f3392738 changes name response transfer 2026-03-16 10:41:43 +07:00
ragilap b2e70fa6eb add restrict create/edit/delete depletion 2026-03-14 15:39:09 +07:00
ragilap 5ba10113c3 add restrict create/edit/delete depletion 2026-03-14 15:38:47 +07:00
ragilap 29956528e5 fixing filter pw for transfer, add transfer delete 2026-03-13 11:22:10 +07:00
ragilap 9dcccabc6a fixing filter product warehouse transfer, cannot take from population 2026-03-11 15:10:49 +07:00
ragilap 333cb9e136 Fix logic recording transition 2026-03-10 17:02:45 +07:00
Adnan Zahir 5c36ef79cb Merge branch 'fix/is-depletion-hidden' into 'development'
fix: is depletion hidden

See merge request mbugroup/lti-api!370
2026-03-09 23:01:03 +07:00
Adnan Zahir da0ec225f1 fix: is depletion hidden attempt 2 2026-03-09 23:00:15 +07:00
Adnan Zahir a1b1841695 fix: is depletion hidden 2026-03-09 22:54:18 +07:00
Adnan Zahir cb9c202b29 Merge branch 'fix/prepare-stmt' into 'development'
[FIX/BE] Change from true to false, in database.go

See merge request mbugroup/lti-api!369
2026-03-09 21:55:14 +07:00
Hafizh A. Y ec7ec2219c fix: change from true to false, in database.go 2026-03-09 21:53:23 +07:00
Adnan Zahir b8164e2e08 Merge branch 'feat/kandang-groups' into 'development'
adjust employee

See merge request mbugroup/lti-api!368
2026-03-09 16:01:11 +07:00
giovanni 3c43426fbc adjust employee 2026-03-09 15:58:57 +07:00
Adnan Zahir 131aa4fcb5 Merge branch 'feat/kandang-groups' into 'development'
[FIX][BE]: adjust api delete group kandang

See merge request mbugroup/lti-api!367
2026-03-09 15:31:49 +07:00
giovanni c1acee7980 adjust api delete group kandang 2026-03-09 15:28:48 +07:00
Adnan Zahir 810aab1448 Merge branch 'feat/kandang-groups' into 'development'
[FEAT][BE]: create master data group kandangs

See merge request mbugroup/lti-api!365
2026-03-09 14:06:55 +07:00
ragilap 3a8cc47fa0 Fix transfer to laying delete and fix chikin delete with response recording 2026-03-09 13:10:06 +07:00
giovanni b93150e8df adjust query daily checklist 2026-03-09 12:59:11 +07:00
giovanni fc532e1202 adjust kandangs 2026-03-09 12:05:14 +07:00
giovanni 71c7207bdb adjust 2026-03-09 11:32:38 +07:00
giovanni 96bef248cb Merge branch 'development' into feat/kandang-groups 2026-03-09 11:17:10 +07:00
giovanni 1dc1feed03 addjust permission 2026-03-09 11:15:53 +07:00
giovanni 5ebdc9dfe4 Adjust permission route 2026-03-09 11:12:38 +07:00
giovanni 65962f8e2b adjust const permission master data daily checkstlist kandang 2026-03-09 10:56:30 +07:00
giovanni 17ed266131 adjust master data kandang and query daily checklist to group kandangs 2026-03-09 10:39:07 +07:00
Adnan Zahir c9fb4077a6 Merge branch 'development' into 'production'
v1: go-live

See merge request mbugroup/lti-api!364
2026-03-09 06:35:39 +07:00
Adnan Zahir 26e782b212 Merge branch 'fix/param-master-product' into 'development'
fix: add include_all param in get all master product

See merge request mbugroup/lti-api!363
2026-03-09 00:51:39 +07:00
Hafizh A. Y 1fc6cb051d fix: add include_all param in get all master product 2026-03-09 00:47:37 +07:00
M1 AIR e6159e4f90 ci: use AWS ECR Public base images for lti builds 2026-03-08 23:55:38 +07:00
Adnan Zahir e80874bad7 Merge remote-tracking branch 'origin/dev/fifo-v2' into development 2026-03-08 23:40:39 +07:00
Adnan Zahir c5a27ef3a6 Merge branch 'Feat/BE/implement-new-trf' into 'dev/fifo-v2'
fix: reimplement transfer to laying logics separating effective financial date...

See merge request mbugroup/lti-api!361
2026-03-08 23:32:31 +07:00
ragilap 45cc057dd4 Fix adjusment stock chickin, transfer to laying and chickin 2026-03-08 23:31:04 +07:00
giovanni 566567e328 first commit add master data kandang group 2026-03-08 23:22:47 +07:00
M1 AIR 3cb2e15629 fix: defer fifo ayam migrations until seed exists 2026-03-08 20:00:22 +07:00
M1 AIR 1c5b013b9f fix: make fifo migrations order-safe for fresh setup 2026-03-08 19:53:24 +07:00
M1 AIR 83650d4486 fix: build migrate binary with postgres driver 2026-03-08 19:41:37 +07:00
M1 AIR 970332f0be chore: add db job manifests for argo dev-lti 2026-03-08 19:18:00 +07:00
Hafizh A. Y. b8fa79a2ab Merge branch 'fix/migration-fifo-v2' into 'dev/fifo-v2'
fix: migration fifo v2

See merge request mbugroup/lti-api!360
2026-03-08 12:02:11 +00:00
Hafizh A. Y b1c829bbf8 fix: migration fifo v2 2026-03-08 19:01:09 +07:00
Adnan Zahir f8ca404bbb Merge remote-tracking branch 'origin/dev/fifo-v2' into development 2026-03-08 15:07:59 +07:00
ragilap fca96df3d9 Merge branch 'dev/fifo-v2' of https://gitlab.com/mbugroup/lti-api into dev/fifo-v2 2026-03-06 11:46:31 +07:00
Hafizh A. Y. 073b098843 Merge branch 'fix/chickin-master-product' into 'dev/fifo-v2'
fix: master product and chickin

See merge request mbugroup/lti-api!358
2026-03-06 03:26:18 +00:00
Hafizh A. Y 650f8e0fdb fix: master product and chickin 2026-03-06 10:24:43 +07:00
Adnan Zahir 7c6a401ac2 Merge branch 'fix/chickin-master-product' into 'dev/fifo-v2'
feat: refactor module adjusment stock, adjust constant, adjust table migration...

See merge request mbugroup/lti-api!357
2026-03-05 17:33:26 +07:00
Hafizh A. Y 345fe32433 fix: flag master data product and fix chickin approve 2026-03-05 17:18:19 +07:00
Adnan Zahir 7f8013c5ed fix: reimplement transfer to laying logics separating effective financial date and physical transfer date 2026-03-05 12:53:00 +07:00
kris ec17633b84 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!356
2026-03-04 17:57:51 +00:00
kris 73dfeb64b0 Merge branch 'test/mr-ci-clean' into 'development'
ci: use prod sha tag for mr image

See merge request mbugroup/lti-api!355
2026-03-04 17:54:01 +00:00
M1 AIR ee216327bb ci: use prod sha tag for mr image 2026-03-05 00:51:28 +07:00
kris b2f235dcde Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!354
2026-03-04 17:45:35 +00:00
kris 7fc52174e0 Merge branch 'test/mr-ci-clean' into 'development'
ci: force amd64 docker build platform

See merge request mbugroup/lti-api!353
2026-03-04 17:40:00 +00:00
M1 AIR 735c8e00d0 ci: force amd64 docker build platform 2026-03-05 00:39:08 +07:00
kris 1a1ea14824 Merge branch 'test/mr-ci-clean' into 'development'
ci: add dev gitops trigger after dev build

See merge request mbugroup/lti-api!352
2026-03-04 17:36:03 +00:00
M1 AIR ed1fb1b776 ci: add dev gitops trigger after dev build 2026-03-05 00:33:11 +07:00
kris 9e77d88c83 Merge branch 'test/mr-ci-clean' into 'development'
ci: align mr development production triggers

See merge request mbugroup/lti-api!351
2026-03-04 17:28:06 +00:00
M1 AIR 70db3cfd34 ci: align mr development production triggers 2026-03-05 00:27:05 +07:00
kris 562d8a90c8 Update .gitlab-ci.yml file 2026-03-04 17:15:34 +00:00
kris 68f77a97a5 Merge branch 'test/mr-ci' into 'development'
ci: use self-hosted-dev runner tags

See merge request mbugroup/lti-api!349
2026-03-04 17:09:38 +00:00
M1 AIR 54e4878406 ci: use self-hosted-dev runner tags 2026-03-05 00:08:50 +07:00
kris 7677057d7c Merge branch 'test/mr-ci' into 'development'
ci: adjust lti gitlab pipeline for ecr gitops

See merge request mbugroup/lti-api!348
2026-03-04 17:05:34 +00:00
M1 AIR 77ac46a029 ci: adjust lti gitlab pipeline for ecr gitops 2026-03-05 00:04:23 +07:00
kris cdd22cf198 Merge branch 'test/mr-ci' into 'development'
Test/mr ci

See merge request mbugroup/lti-api!347
2026-03-04 16:49:06 +00:00
M1 AIR 8a006f377e Update flow environment 2026-03-04 23:45:06 +07:00
Adnan Zahir 1b6041073e Merge branch 'feat/BE/restriction_growing_trl' into 'dev/fifo-v2'
Fix config chickin

See merge request mbugroup/lti-api!346
2026-03-04 14:54:27 +07:00
Adnan Zahir f082c5c122 Merge branch 'feat/BE/restriction_growing_trl' into 'dev/fifo-v2'
[FEAT/BE] wiring recording,transfer_stock,transfer_laying,marketing for...

See merge request mbugroup/lti-api!345
2026-03-04 14:30:40 +07:00
kris 55451a5ea8 Update .gitlab-ci.yml file 2026-03-03 07:16:29 +00:00
kris aafbba199d Update .gitlab-ci.yml file 2026-03-03 07:06:05 +00:00
kris 0206d9dc68 Edit development.yml 2026-03-03 05:34:21 +00:00
kris e9d1cca294 Edit development.yml 2026-03-03 05:12:05 +00:00
Adnan Zahir 0bcda8ad82 Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!339
2026-02-26 16:36:07 +07:00
Adnan Zahir aadc19a3ca Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!335
2026-02-26 16:17:04 +07:00
Adnan Zahir 4d009978ae Merge branch 'fix/be/closing-unclosing' into 'development'
[FEAT/BE] fixing overhead,sapronak,perhitungan sapronak

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

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

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

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

See merge request mbugroup/lti-api!331
2026-02-20 09:53:22 +07:00
Adnan Zahir e323f42c11 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!323
2026-02-12 11:04:50 +07:00
Adnan Zahir 0d5044b7bf Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!320
2026-02-07 17:07:43 +07:00
Adnan Zahir dd2832b8fc Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!319
2026-02-07 17:00:36 +07:00
M1 AIR 18db58a87b test: MR pipeline notification 2026-02-07 00:14:58 +07:00
kris 119a5e4e25 Merge branch 'staging' into 'production'
FEAT[BE] :refactor egg production data retrieval to use date parameter in...

See merge request mbugroup/lti-api!317
2026-02-06 17:06:51 +00:00
kris 04068c2a8b Merge branch 'development' into 'staging'
Create job for MR

See merge request mbugroup/lti-api!316
2026-02-06 16:51:05 +00:00
Adnan Zahir 0db1aaaab7 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!314
2026-02-06 11:31:55 +07:00
Adnan Zahir 2b258908ef Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!310
2026-02-05 10:32:25 +07:00
Adnan Zahir 74e5542726 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!299
2026-02-03 13:05:23 +07:00
Adnan Zahir 2f5ddfe8a6 Merge branch 'staging' into 'production'
Staging 31 Jan 2026

See merge request mbugroup/lti-api!293
2026-01-31 10:58:02 +07:00
Adnan Zahir ceba7c5543 Merge branch 'production' into 'staging'
Production

See merge request mbugroup/lti-api!291
2026-01-31 10:51:33 +07:00
Adnan Zahir b32789e515 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!290
2026-01-31 10:42:07 +07:00
Adnan Zahir a7611ad0b2 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!287
2026-01-30 14:43:56 +07:00
Adnan Zahir 0042cf11ce Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!285
2026-01-30 11:42:14 +07:00
Adnan Zahir b860a68db2 Merge branch 'development' into 'staging'
changes permission to redis and scope

See merge request mbugroup/lti-api!282
2026-01-29 19:12:27 +07:00
210 changed files with 20785 additions and 3108 deletions
+179 -28
View File
@@ -1,35 +1,186 @@
stages:
- build
- gitops
variables:
AWS_REGION: ap-southeast-3
ECR_REGISTRY: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com
ECR_REPO_NAME: mbugroup/lti-api
ECR_REPOSITORY: ${ECR_REGISTRY}/${ECR_REPO_NAME}
TARGET_PLATFORM: linux/amd64
DOCKER_HOST: unix:///var/run/docker.sock
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
workflow:
rules:
# MR pipeline
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: always
# Push pipeline hanya untuk env branch
- if: '$CI_COMMIT_BRANCH == "development"'
when: always
- if: '$CI_COMMIT_BRANCH == "staging"'
when: always
- if: '$CI_COMMIT_BRANCH == "production"'
when: always
# Selain itu jangan buat pipeline
# run untuk branch utama & MR
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- when: never
include:
# khusus MR (notif)
- local: "ci/merge_request.yml"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# =========================
# Helper: login ECR
# =========================
.ecr_login: &ecr_login |
AWS_CLI_ENV_ARGS=""
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}"
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
fi
# khusus push ke branch env
- local: "ci/development.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
ecr get-login-password --region "$AWS_REGION" || true)"
if [ -z "$PASS" ]; then
echo "ERROR: Failed to get ECR login password."
exit 1
fi
echo "$PASS" | docker login --username AWS --password-stdin "$ECR_REGISTRY"
- local: "ci/staging.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
# =========================
# MR
# =========================
build_mr:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
variables:
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
echo "Build (MR) : $ECR_REPOSITORY:$IMAGE_TAG"
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
echo "Pushing image for MR..."
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
- local: "ci/production.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
# =========================
# DEVELOPMENT (push branch development)
# =========================
build_push_dev:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
echo "Build & push (dev): $ECR_REPOSITORY:$IMAGE_TAG"
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
update_gitops_dev_lti:
stage: gitops
image: public.ecr.aws/docker/library/alpine:3.20
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
needs: ["build_push_dev"]
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
GITOPS_BRANCH: main
VALUES_FILE: environments/lti/dev/lti-values-dev.yaml
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
before_script:
- set -eu
- apk add --no-cache git yq
- git config --global user.email "ci@gitlab"
- git config --global user.name "gitlab-ci"
script: |
set -eu
rm -rf gitops
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
cd gitops
echo "Updating dev image.tag to $IMAGE_TAG"
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
git add "$VALUES_FILE"
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "lti dev deploy ${IMAGE_TAG}"
git push origin "$GITOPS_BRANCH"
# =========================
# PRODUCTION (push branch production)
# =========================
build_push_prod:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
variables:
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
echo "Build & push (prod): $ECR_REPOSITORY:$IMAGE_TAG"
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
update_gitops_prod_lti:
stage: gitops
image: public.ecr.aws/docker/library/alpine:3.20
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
needs: ["build_push_prod"]
variables:
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
GITOPS_BRANCH: main
VALUES_FILE: environments/lti/prod/lti-values-prod.yaml
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
before_script:
- set -eu
- apk add --no-cache git yq
- git config --global user.email "ci@gitlab"
- git config --global user.name "gitlab-ci"
script: |
set -eu
rm -rf gitops
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
cd gitops
echo "Updating prod image.tag to $IMAGE_TAG"
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
git add "$VALUES_FILE"
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "lti prod deploy ${IMAGE_TAG}"
git push origin "$GITOPS_BRANCH"
+8 -3
View File
@@ -1,7 +1,7 @@
# =========================
# Builder stage
# =========================
FROM golang:1.23-alpine AS builder
FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder
RUN apk add --no-cache git ca-certificates tzdata
WORKDIR /app
@@ -15,14 +15,17 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api
# Build SEED binary (pastikan cmd/seed ada)
# Build SEED binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-seed ./cmd/seed
# Build migrate CLI with postgres + file drivers
RUN GOBIN=/usr/local/bin go install -tags "postgres file" -ldflags="-s -w" github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.3
# =========================
# Runtime stage
# =========================
FROM alpine:3.20
FROM public.ecr.aws/docker/library/alpine:3.20
RUN apk add --no-cache ca-certificates tzdata curl bash postgresql-client \
&& adduser -D -H -u 10001 appuser
@@ -31,6 +34,8 @@ WORKDIR /app
COPY --from=builder /app/lti-api /app/lti-api
COPY --from=builder /app/lti-seed /app/lti-seed
COPY --from=builder /usr/local/bin/migrate /app/migrate
COPY --from=builder /app/internal/database/migrations /app/migrations
USER appuser
EXPOSE 8081
+1
View File
@@ -111,3 +111,4 @@ IT Development PT Mitra Berlian Unggas Group
## 📃 License
> This project is private. All rights reserved.
# mr test Sat 7 Feb 2026 00:14:58 WIB
+141 -73
View File
@@ -1,91 +1,159 @@
stages:
- deploy
- build
- gitops
deploy-dev:
stage: deploy
image: alpine:3.20
variables:
AWS_REGION: ap-southeast-3
ECR_REGISTRY: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com
ECR_REPO_NAME: mbugroup/lti-api
ECR_REPOSITORY: ${ECR_REGISTRY}/${ECR_REPO_NAME}
DOCKER_HOST: unix:///var/run/docker.sock
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
workflow:
rules:
- if: '$CI_COMMIT_BRANCH'
# =========================
# Helper: login ECR
# =========================
.ecr_login: &ecr_login |
AWS_CLI_ENV_ARGS=""
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}"
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
fi
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
ecr get-login-password --region "$AWS_REGION" || true)"
if [ -z "$PASS" ]; then
echo "ERROR: Failed to get ECR login password."
exit 1
fi
echo "$PASS" | docker login --username AWS --password-stdin "$ECR_REGISTRY"
# =========================
# DEV
# =========================
build_push_dev_lti:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
when: on_success
- when: never
variables:
DEPLOY_APP: "LTI-MBUGROUP"
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
before_script:
- echo "🧰 Installing dependencies..."
- apk update && apk add --no-cache openssh git curl bash
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
echo "Build & push: $ECR_REPOSITORY:$IMAGE_TAG"
# 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
docker build \
-t "$ECR_REPOSITORY:$IMAGE_TAG" \
.
# 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
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
update_gitops_dev_lti:
stage: gitops
image: public.ecr.aws/docker/library/alpine:3.20
tags: [self-hosted-dev]
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
needs: ["build_push_dev_lti"]
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
GITOPS_BRANCH: main
VALUES_FILE: environments/lti/dev/lti-values-dev.yaml
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
before_script:
- set -eu
- apk add --no-cache git yq
- git config --global user.email "ci@gitlab"
- git config --global user.name "gitlab-ci"
script: |
set -eu
rm -rf gitops
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
cd gitops
cd /home/devops/docker/deployment/development/lti-api
echo "Updating DEV image.tag to $IMAGE_TAG in $VALUES_FILE"
yq -i '.image.repository = strenv(ECR_REPOSITORY)' "$VALUES_FILE"
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
git add "$VALUES_FILE"
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "lti dev deploy ${IMAGE_TAG}"
git push origin "$GITOPS_BRANCH"
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
# =========================
# PROD
# =========================
# build_push_prod_lti:
# stage: build
# image: public.ecr.aws/docker/library/docker:27
# tags: [self-hosted-dev]
# rules:
# - if: '$CI_COMMIT_BRANCH == "production"'
# variables:
# IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
# before_script:
# - set -eu
# - docker version
# - docker info
# - *ecr_login
# script: |
# set -eu
# echo "Build & push: $ECR_REPOSITORY:$IMAGE_TAG"
# Fetch/reset pakai SSH
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
git reset --hard origin/development
# docker build \
# -t "$ECR_REPOSITORY:$IMAGE_TAG" \
# .
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
"; then
STATUS='success';
else
STATUS='failed';
fi;
# docker push "$ECR_REPOSITORY:$IMAGE_TAG"
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
# update_gitops_prod_lti:
# stage: gitops
# image: public.ecr.aws/docker/library/alpine:3.20
# tags: [self-hosted-dev]
# rules:
# - if: '$CI_COMMIT_BRANCH == "production"'
# needs: ["build_push_prod_lti"]
# variables:
# IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
# GITOPS_BRANCH: main
# VALUES_FILE: environments/lti/prod/lti-values-prod.yaml
# GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
# before_script:
# - set -eu
# - apk add --no-cache git yq
# - git config --global user.email "ci@gitlab"
# - git config --global user.name "gitlab-ci"
# script: |
# set -eu
# rm -rf gitops
# git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
# cd gitops
if [ "$STATUS" = "success" ]; then
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 "Updating PROD image.tag to $IMAGE_TAG in $VALUES_FILE"
# yq -i '.image.repository = strenv(ECR_REPOSITORY)' "$VALUES_FILE"
# yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
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
# git add "$VALUES_FILE"
# if git diff --cached --quiet; then
# echo "No changes to commit"
# exit 0
# fi
# git commit -m "lti prod deploy ${IMAGE_TAG}"
# git push origin "$GITOPS_BRANCH"
@@ -0,0 +1,297 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
const (
levelAllNoFlagProducts = 1
levelProductName = 2
levelProductWarehouse = 3
qtyEpsilon = 1e-6
)
type targetRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
CurrentQty float64 `gorm:"column:current_qty"`
ComputedQty float64 `gorm:"column:computed_qty"`
}
func main() {
var (
level int
productName string
productWarehouseID uint
apply bool
)
flag.IntVar(
&level,
"level",
levelAllNoFlagProducts,
"CLI level: 1=all products without flags, 2=specific product name (with flags), 3=specific product warehouse id",
)
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
productName = strings.TrimSpace(productName)
if err := validateFlags(level, productName, productWarehouseID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
targets, err := loadTargets(ctx, db, level, productName, productWarehouseID)
if err != nil {
log.Fatalf("failed to load target product warehouses: %v", err)
}
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
if productName != "" {
fmt.Printf("Filter product_name: %s\n", productName)
}
if productWarehouseID > 0 {
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
}
fmt.Printf("Targets found: %d\n\n", len(targets))
if len(targets) == 0 {
fmt.Println("No matching product warehouse rows to process")
return
}
for _, row := range targets {
fmt.Printf(
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f computed_qty=%.3f delta=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
row.ComputedQty-row.CurrentQty,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0\n", len(targets))
return
}
updated := 0
skipped := 0
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, row := range targets {
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
fmt.Printf(
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
row.ProductWarehouseID,
row.CurrentQty,
row.ComputedQty,
)
skipped++
continue
}
if err := tx.Table("product_warehouses").
Where("id = ?", row.ProductWarehouseID).
Update("qty", row.ComputedQty).Error; err != nil {
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
}
fmt.Printf(
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
)
updated++
}
return nil
})
if err != nil {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=1\n", len(targets), updated, skipped)
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=0\n", len(targets), updated, skipped)
}
func validateFlags(level int, productName string, productWarehouseID uint) error {
switch level {
case levelAllNoFlagProducts:
if productName != "" {
return errors.New("--product-name cannot be used on level 1")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 1")
}
case levelProductName:
if productName == "" {
return errors.New("--product-name is required on level 2")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 2")
}
case levelProductWarehouse:
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required on level 3")
}
if productName != "" {
return errors.New("--product-name cannot be used on level 3")
}
default:
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
}
return nil
}
func loadTargets(
ctx context.Context,
db *gorm.DB,
level int,
productName string,
productWarehouseID uint,
) ([]targetRow, error) {
switch level {
case levelAllNoFlagProducts:
return loadTargetsLevel1ByProductWithoutFlags(ctx, db)
case levelProductName:
return loadTargetsLevel2ByProductWarehouseWithFlags(ctx, db, productName)
case levelProductWarehouse:
return loadTargetByProductWarehouseID(ctx, db, productWarehouseID)
default:
return nil, fmt.Errorf("unsupported level %d", level)
}
}
func loadTargetsLevel1ByProductWithoutFlags(ctx context.Context, db *gorm.DB) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("p.deleted_at IS NULL").
Where("f.id IS NULL").
Group("pw.id, pw.product_id, p.name, pw.qty").
Order("pw.id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadTargetsLevel2ByProductWarehouseWithFlags(
ctx context.Context,
db *gorm.DB,
productName string,
) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("p.deleted_at IS NULL").
Where(`
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_id = p.id
AND f.flagable_type = ?
)
`, entity.FlagableTypeProduct).
Where("LOWER(p.name) = LOWER(?)", productName).
Group("pw.id, pw.product_id, p.name, pw.qty").
Order("pw.id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadTargetByProductWarehouseID(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
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.id = ?", productWarehouseID).
Group("pw.id, pw.product_id, p.name, pw.qty").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func levelLabel(level int) string {
switch level {
case levelAllNoFlagProducts:
return "all products without flags (source: purchase_items by product_warehouse_id)"
case levelProductName:
return "specific product name with flags (source: purchase_items by product_warehouse_id)"
case levelProductWarehouse:
return "specific product_warehouse_id (source: purchase_items by product_warehouse_id)"
default:
return "unknown"
}
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
package main
import (
"context"
"testing"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
)
func TestValidateAdjustmentGatherAgainstAllowedIDsEligible(t *testing.T) {
result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11, 12}, []commonSvc.FifoStockV2GatherRow{
{SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 70},
{SourceTable: "adjustment_stocks", SourceID: 12, AvailableQuantity: 40},
})
if result.Status != "eligible" {
t.Fatalf("expected eligible, got %+v", result)
}
if result.VerifiedQty != 100 {
t.Fatalf("expected verified qty 100, got %v", result.VerifiedQty)
}
}
func TestValidateAdjustmentGatherAgainstAllowedIDsRejectsMixedSource(t *testing.T) {
result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11}, []commonSvc.FifoStockV2GatherRow{
{SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 60},
{SourceTable: "recording_eggs", SourceID: 21, AvailableQuantity: 50},
})
if result.Status != "skipped" {
t.Fatalf("expected skipped, got %+v", result)
}
if result.Reason != "mixed_fifo_source_recording_eggs" {
t.Fatalf("unexpected reason: %+v", result)
}
}
func TestBuildAdjustmentMigrationPlanUsesValidator(t *testing.T) {
opts := &adjustmentCommandOptions{RunID: "egg-adjustment-cutover-test"}
farmID := uint(25)
farmName := "Gudang Farm Jamali"
rows := []adjustmentLegacyEggRow{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 101,
ProductID: 8,
ProductName: "Telur Utuh",
RemainingQty: 120,
CurrentPWQty: 150,
AdjustmentIDs: []uint{1},
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 102,
ProductID: 9,
ProductName: "Telur Putih",
RemainingQty: 20,
CurrentPWQty: 40,
AdjustmentIDs: []uint{2},
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
ProductWarehouseID: 103,
ProductID: 10,
ProductName: "Telur Pecah",
RemainingQty: 10,
CurrentPWQty: 10,
AdjustmentIDs: []uint{3},
},
}
validator := &fakeAdjustmentCandidateValidator{
byProduct: map[string]adjustmentCandidateValidation{
"Telur Utuh": {Status: "eligible", VerifiedQty: 120},
"Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10},
},
}
reportRows, groups := buildAdjustmentMigrationPlan(context.Background(), opts, map[uint]adjustmentLocationTiming{
16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"},
}, rows, validator)
if len(reportRows) != 3 {
t.Fatalf("expected 3 report rows, got %d", len(reportRows))
}
if len(groups) != 1 || len(groups[0].Rows) != 1 {
t.Fatalf("expected only one eligible grouped row, got %+v", groups)
}
if reportRows[0].Status != "eligible" || reportRows[0].VerifiedQty != 120 {
t.Fatalf("unexpected first row: %+v", reportRows[0])
}
if reportRows[1].Reason != "mixed_fifo_source_recording_eggs" {
t.Fatalf("unexpected second row reason: %+v", reportRows[1])
}
if reportRows[2].Reason != "missing_farm_warehouse" {
t.Fatalf("expected missing farm warehouse skip, got %+v", reportRows[2])
}
}
func TestExecuteAdjustmentApplyRevalidatesRowsAndAppliesSubset(t *testing.T) {
opts := &adjustmentCommandOptions{
RunID: "egg-adjustment-cutover-apply",
CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
ActorID: 99,
}
group := adjustmentTransferGroup{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: 25,
FarmWarehouseName: "Gudang Farm Jamali",
Rows: []*adjustmentMigrationReportRow{
{LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 101, ProductID: 8, ProductName: "Telur Utuh", RemainingQty: 120, CurrentPWQty: 150, AdjustmentIDs: []uint{1}, Status: "eligible"},
{LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 102, ProductID: 9, ProductName: "Telur Putih", RemainingQty: 20, CurrentPWQty: 40, AdjustmentIDs: []uint{2}, Status: "eligible"},
},
}
validator := &fakeAdjustmentCandidateValidator{
byProduct: map[string]adjustmentCandidateValidation{
"Telur Utuh": {Status: "eligible", VerifiedQty: 120},
"Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10},
},
}
executor := &fakeAdjustmentSystemTransferExecutor{
createResponses: []*entity.StockTransfer{
{Id: 1001, MovementNumber: "PND-LTI-1001"},
},
}
summary, err := executeAdjustmentApply(context.Background(), executor, validator, opts, []adjustmentTransferGroup{group})
if err != nil {
t.Fatalf("expected no fatal apply error, got %v", err)
}
if summary.GroupsApplied != 1 {
t.Fatalf("expected 1 applied group, got %+v", summary)
}
if summary.RowsApplied != 1 || summary.RowsFailed != 1 {
t.Fatalf("unexpected summary: %+v", summary)
}
if len(executor.createRequests) != 1 {
t.Fatalf("expected 1 create request, got %d", len(executor.createRequests))
}
if len(executor.createRequests[0].Products) != 1 || executor.createRequests[0].Products[0].ProductID != 8 {
t.Fatalf("expected only Telur Utuh to be transferred, got %+v", executor.createRequests[0].Products)
}
}
type fakeAdjustmentCandidateValidator struct {
byProduct map[string]adjustmentCandidateValidation
errByProduct map[string]error
}
func (f *fakeAdjustmentCandidateValidator) ValidateCandidate(ctx context.Context, row adjustmentLegacyEggRow) (adjustmentCandidateValidation, error) {
if err, ok := f.errByProduct[row.ProductName]; ok {
return adjustmentCandidateValidation{}, err
}
if result, ok := f.byProduct[row.ProductName]; ok {
return result, nil
}
return adjustmentCandidateValidation{Status: "eligible", VerifiedQty: row.RemainingQty}, nil
}
type fakeAdjustmentSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer
createErrors []error
deletedTransferIDs []uint
deleteErrors map[uint]error
}
func (f *fakeAdjustmentSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) {
f.createRequests = append(f.createRequests, req)
idx := len(f.createRequests) - 1
if idx < len(f.createErrors) && f.createErrors[idx] != nil {
return nil, f.createErrors[idx]
}
if idx < len(f.createResponses) && f.createResponses[idx] != nil {
return f.createResponses[idx], nil
}
return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil
}
func (f *fakeAdjustmentSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
if f.deleteErrors == nil {
return nil
}
return f.deleteErrors[id]
}
func uintPtr(v uint) *uint { return &v }
func strPtr(v string) *string { return &v }
var _ adjustmentCandidateValidator = (*fakeAdjustmentCandidateValidator)(nil)
var _ adjustmentSystemTransferExecutor = (*fakeAdjustmentSystemTransferExecutor)(nil)
var _ commonSvc.FifoStockV2Lane = fifoStockV2.LaneStockable
@@ -0,0 +1,825 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"sort"
"strings"
"text/tabwriter"
"time"
"github.com/go-playground/validator/v10"
"github.com/sirupsen/logrus"
"gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
pwRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
transferRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
pfkRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gorm.io/gorm"
)
const (
cutoverReasonPrefix = "EGG_FARM_CUTOVER"
outputModeTable = "table"
outputModeJSON = "json"
)
type commandOptions struct {
Apply bool
DryRun bool
RollbackRunID string
LocationID uint
LocationName string
CutoverDate time.Time
CutoverDateRaw string
IncludeOverlap bool
Output string
ActorID uint
RunID string
}
type locationTiming struct {
LocationID uint
LocationName string
FirstKandangDate *time.Time
LastKandangDate *time.Time
FirstFarmDate *time.Time
LastFarmDate *time.Time
Status string
}
type legacyEggStockRow struct {
LocationID uint
LocationName string
SourceWarehouseID uint
SourceWarehouseName string
FarmWarehouseID *uint
FarmWarehouseName *string
ProductWarehouseID uint
ProductID uint
ProductName string
OnHandQty float64
}
type migrationReportRow struct {
RunID string `json:"run_id"`
LocationID uint `json:"location_id"`
LocationName string `json:"location_name"`
SourceWarehouseID uint `json:"source_warehouse_id"`
SourceWarehouseName string `json:"source_warehouse_name"`
FarmWarehouseID *uint `json:"farm_warehouse_id,omitempty"`
FarmWarehouseName *string `json:"farm_warehouse_name,omitempty"`
ProductWarehouseID uint `json:"product_warehouse_id"`
ProductID uint `json:"product_id"`
ProductName string `json:"product_name"`
Qty float64 `json:"qty"`
LocationStatus string `json:"location_status"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
TransferID *uint64 `json:"transfer_id,omitempty"`
MovementNumber *string `json:"movement_number,omitempty"`
}
type applySummary struct {
RowsPlanned int `json:"rows_planned"`
RowsApplied int `json:"rows_applied"`
RowsSkipped int `json:"rows_skipped"`
RowsFailed int `json:"rows_failed"`
GroupsPlanned int `json:"groups_planned"`
GroupsApplied int `json:"groups_applied"`
}
type rollbackDetailRow struct {
RunID string `json:"run_id"`
TransferID uint64 `json:"transfer_id"`
MovementNumber string `json:"movement_number"`
LocationName string `json:"location_name"`
SourceWarehouseName string `json:"source_warehouse_name"`
FarmWarehouseName string `json:"farm_warehouse_name"`
ProductName string `json:"product_name"`
Qty float64 `json:"qty"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
}
type systemTransferExecutor interface {
CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
}
type transferGroup struct {
LocationID uint
LocationName string
SourceWarehouseID uint
SourceWarehouseName string
FarmWarehouseID uint
FarmWarehouseName string
Rows []*migrationReportRow
}
func main() {
opts, err := parseFlags()
if err != nil {
log.Fatalf("invalid flags: %v", err)
}
db := database.Connect(config.DBHost, config.DBName)
ctx := context.Background()
if strings.TrimSpace(opts.RollbackRunID) != "" {
rows, err := loadRollbackDetails(ctx, db, opts.RollbackRunID)
if err != nil {
log.Fatalf("failed to load rollback details: %v", err)
}
if !opts.Apply {
for i := range rows {
rows[i].Status = "eligible"
}
renderRollbackReport(opts.Output, rows)
return
}
if err := executeRollback(ctx, newSystemTransferService(db), rows, opts.ActorID); err != nil {
log.Fatalf("rollback failed: %v", err)
}
renderRollbackReport(opts.Output, rows)
return
}
timings, err := loadLocationTimings(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load location timings: %v", err)
}
legacyRows, err := loadLegacyEggStocks(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load legacy egg stocks: %v", err)
}
reportRows, groups := buildMigrationPlan(opts, timings, legacyRows)
if !opts.Apply {
renderMigrationReport(opts.Output, reportRows, summarizeApply(reportRows, groups, 0))
return
}
summary, err := executeApply(ctx, newSystemTransferService(db), opts, groups)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
finalRows := flattenGroups(groups, reportRows)
summary = summarizeApply(finalRows, groups, summary.GroupsApplied)
renderMigrationReport(opts.Output, finalRows, summary)
}
func parseFlags() (*commandOptions, error) {
var opts commandOptions
flag.BoolVar(&opts.Apply, "apply", false, "Apply migration. If false, run as dry-run")
flag.BoolVar(&opts.DryRun, "dry-run", true, "Run as dry-run")
flag.StringVar(&opts.RollbackRunID, "rollback-run-id", "", "Rollback all transfers created by the provided run id")
flag.UintVar(&opts.LocationID, "location-id", 0, "Filter by location id")
flag.StringVar(&opts.LocationName, "location-name", "", "Filter by exact location name")
flag.StringVar(&opts.CutoverDateRaw, "cutover-date", "", "Cutover date in YYYY-MM-DD format")
flag.BoolVar(&opts.IncludeOverlap, "include-overlap", false, "Include overlap locations in plan/apply")
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
flag.UintVar(&opts.ActorID, "actor-id", 1, "Actor id used for created/deleted transfers")
flag.Parse()
opts.LocationName = strings.TrimSpace(opts.LocationName)
opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID)
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
if opts.Output == "" {
opts.Output = outputModeTable
}
if opts.Output != outputModeTable && opts.Output != outputModeJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
if opts.Apply {
opts.DryRun = false
}
if opts.LocationID > 0 && opts.LocationName != "" {
return nil, errors.New("use either --location-id or --location-name, not both")
}
if opts.RollbackRunID != "" {
if opts.LocationID > 0 || opts.LocationName != "" {
return nil, errors.New("location filters are not supported with --rollback-run-id")
}
if opts.CutoverDateRaw != "" {
return nil, errors.New("--cutover-date is not used with --rollback-run-id")
}
} else if opts.Apply {
if opts.LocationID == 0 && opts.LocationName == "" {
return nil, errors.New("apply mode requires --location-id or --location-name for safety")
}
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
return nil, errors.New("--cutover-date is required in apply mode")
}
}
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
opts.CutoverDate = normalizeDateOnly(time.Now().In(time.FixedZone("Asia/Jakarta", 7*3600)))
} else {
t, err := time.Parse("2006-01-02", opts.CutoverDateRaw)
if err != nil {
return nil, fmt.Errorf("invalid --cutover-date: %w", err)
}
opts.CutoverDate = normalizeDateOnly(t)
}
opts.RunID = buildRunID()
return &opts, nil
}
func newSystemTransferService(db *gorm.DB) systemTransferExecutor {
validate := validator.New()
stockTransferRepo := transferRepo.NewStockTransferRepository(db)
stockTransferDetailRepo := transferRepo.NewStockTransferDetailRepository(db)
stockTransferDeliveryRepo := transferRepo.NewStockTransferDeliveryRepository(db)
stockTransferDeliveryItemRepo := transferRepo.NewStockTransferDeliveryItemRepository(db)
stockLogsRepo := stockLogRepo.NewStockLogRepository(db)
productWarehouseRepo := pwRepo.NewProductWarehouseRepository(db)
warehouseRepository := warehouseRepo.NewWarehouseRepository(db)
projectFlockKandangRepo := pfkRepo.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := pfkRepo.NewProjectFlockPopulationRepository(db)
fifoSvc := service.NewFifoStockV2Service(db, logrus.StandardLogger())
return transferSvc.NewTransferService(
validate,
stockTransferRepo,
stockTransferDetailRepo,
stockTransferDeliveryRepo,
stockTransferDeliveryItemRepo,
stockLogsRepo,
productWarehouseRepo,
nil,
warehouseRepository,
projectFlockKandangRepo,
projectFlockPopulationRepo,
nil,
fifoSvc,
nil,
)
}
func loadLocationTimings(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]locationTiming, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"`
FirstKandangDate *time.Time `gorm:"column:first_kandang_date"`
LastKandangDate *time.Time `gorm:"column:last_kandang_date"`
FirstFarmDate *time.Time `gorm:"column:first_farm_date"`
LastFarmDate *time.Time `gorm:"column:last_farm_date"`
}
query := db.WithContext(ctx).
Table("recording_eggs re").
Select(`
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
`).
Joins("JOIN recordings r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)").
Joins("JOIN project_flocks pf ON pf.id = pk.project_flock_id").
Joins("JOIN locations l ON l.id = pf.location_id").
Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Group("pf.location_id, l.name")
query = applyTimingLocationFilter(query, opts)
var rows []row
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]locationTiming, len(rows))
for _, row := range rows {
status := "KANDANG_ONLY"
if row.FirstFarmDate != nil {
status = "OVERLAP"
if row.LastKandangDate == nil || row.FirstFarmDate.After(normalizeDateOnly(*row.LastKandangDate)) {
status = "CLEAN_CUTOVER"
}
}
result[row.LocationID] = locationTiming{
LocationID: row.LocationID,
LocationName: row.LocationName,
FirstKandangDate: normalizeDatePtr(row.FirstKandangDate),
LastKandangDate: normalizeDatePtr(row.LastKandangDate),
FirstFarmDate: normalizeDatePtr(row.FirstFarmDate),
LastFarmDate: normalizeDatePtr(row.LastFarmDate),
Status: status,
}
}
return result, nil
}
func loadLegacyEggStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]legacyEggStockRow, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"`
SourceWarehouseID uint `gorm:"column:source_warehouse_id"`
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
FarmWarehouseID *uint `gorm:"column:farm_warehouse_id"`
FarmWarehouseName *string `gorm:"column:farm_warehouse_name"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
OnHandQty float64 `gorm:"column:on_hand_qty"`
}
firstFarmSub := db.WithContext(ctx).
Table("warehouses fw").
Select("fw.location_id AS location_id, MIN(fw.id) AS farm_warehouse_id").
Where("fw.deleted_at IS NULL").
Where("fw.type = ?", "LOKASI").
Group("fw.location_id")
query := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
kw.location_id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
`).
Joins("JOIN warehouses kw ON kw.id = pw.warehouse_id AND kw.deleted_at IS NULL").
Joins("JOIN locations l ON l.id = kw.location_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN product_categories pc ON pc.id = p.product_category_id").
Joins("LEFT JOIN (?) ff ON ff.location_id = kw.location_id", firstFarmSub).
Joins("LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id").
Where("kw.type = ?", "KANDANG").
Where(`
EXISTS (
SELECT 1
FROM recording_eggs re
WHERE re.product_warehouse_id = pw.id
)
`).
Where(`
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_type = ?
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = ?
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
`, entity.FlagableTypeProduct, entity.FlagableTypeProduct).
Order("l.name ASC, kw.name ASC, p.name ASC")
query = applyLegacyStockLocationFilter(query, opts)
var rows []row
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
result := make([]legacyEggStockRow, 0, len(rows))
for _, row := range rows {
result = append(result, legacyEggStockRow{
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: row.FarmWarehouseID,
FarmWarehouseName: row.FarmWarehouseName,
ProductWarehouseID: row.ProductWarehouseID,
ProductID: row.ProductID,
ProductName: row.ProductName,
OnHandQty: row.OnHandQty,
})
}
return result, nil
}
func buildMigrationPlan(
opts *commandOptions,
timings map[uint]locationTiming,
rows []legacyEggStockRow,
) ([]migrationReportRow, []transferGroup) {
reportRows := make([]migrationReportRow, 0, len(rows))
groupMap := make(map[string]*transferGroup)
for _, row := range rows {
locationStatus := "UNKNOWN"
if timing, ok := timings[row.LocationID]; ok {
locationStatus = timing.Status
}
report := migrationReportRow{
RunID: opts.RunID,
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: row.FarmWarehouseID,
FarmWarehouseName: row.FarmWarehouseName,
ProductWarehouseID: row.ProductWarehouseID,
ProductID: row.ProductID,
ProductName: row.ProductName,
Qty: row.OnHandQty,
LocationStatus: locationStatus,
Status: "eligible",
}
switch {
case row.FarmWarehouseID == nil || row.FarmWarehouseName == nil:
report.Status = "skipped"
report.Reason = "missing_farm_warehouse"
case row.OnHandQty <= 0:
report.Status = "skipped"
report.Reason = "non_positive_qty"
case locationStatus == "OVERLAP" && !opts.IncludeOverlap:
report.Status = "skipped"
report.Reason = "overlap_location"
}
reportRows = append(reportRows, report)
if report.Status != "eligible" {
continue
}
groupKey := fmt.Sprintf("%d:%d", row.SourceWarehouseID, *row.FarmWarehouseID)
group := groupMap[groupKey]
if group == nil {
group = &transferGroup{
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: *row.FarmWarehouseID,
FarmWarehouseName: derefString(row.FarmWarehouseName),
}
groupMap[groupKey] = group
}
group.Rows = append(group.Rows, &reportRows[len(reportRows)-1])
}
groups := make([]transferGroup, 0, len(groupMap))
for _, group := range groupMap {
sort.Slice(group.Rows, func(i, j int) bool {
return group.Rows[i].ProductName < group.Rows[j].ProductName
})
groups = append(groups, *group)
}
sort.Slice(groups, func(i, j int) bool {
if groups[i].LocationName == groups[j].LocationName {
return groups[i].SourceWarehouseName < groups[j].SourceWarehouseName
}
return groups[i].LocationName < groups[j].LocationName
})
return reportRows, groups
}
func executeApply(
ctx context.Context,
svc systemTransferExecutor,
opts *commandOptions,
groups []transferGroup,
) (applySummary, error) {
summary := applySummary{GroupsPlanned: len(groups)}
for _, group := range groups {
products := make([]transferSvc.SystemTransferProduct, 0, len(group.Rows))
for _, row := range group.Rows {
products = append(products, transferSvc.SystemTransferProduct{
ProductID: row.ProductID,
ProductQty: row.Qty,
})
}
reason := buildCutoverReason(opts.RunID, group.LocationName, opts.CutoverDate)
transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{
TransferReason: reason,
TransferDate: opts.CutoverDate,
SourceWarehouseID: group.SourceWarehouseID,
DestinationWarehouseID: group.FarmWarehouseID,
Products: products,
ActorID: opts.ActorID,
StockLogNotes: reason,
})
if err != nil {
for _, row := range group.Rows {
row.Status = "failed"
row.Reason = err.Error()
summary.RowsFailed++
}
continue
}
summary.GroupsApplied++
for _, row := range group.Rows {
row.Status = "applied"
row.TransferID = &transfer.Id
row.MovementNumber = &transfer.MovementNumber
summary.RowsApplied++
}
}
for _, group := range groups {
summary.RowsPlanned += len(group.Rows)
}
return summary, nil
}
func executeRollback(
ctx context.Context,
svc systemTransferExecutor,
rows []rollbackDetailRow,
actorID uint,
) error {
if actorID == 0 {
return fmt.Errorf("actor id is required for rollback")
}
byTransfer := make(map[uint64][]int)
for idx, row := range rows {
byTransfer[row.TransferID] = append(byTransfer[row.TransferID], idx)
}
transferIDs := make([]uint64, 0, len(byTransfer))
for transferID := range byTransfer {
transferIDs = append(transferIDs, transferID)
}
sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] })
var firstErr error
for _, transferID := range transferIDs {
err := svc.DeleteSystemTransfer(ctx, uint(transferID), actorID)
for _, idx := range byTransfer[transferID] {
if err != nil {
rows[idx].Status = "failed"
rows[idx].Reason = err.Error()
} else {
rows[idx].Status = "rolled_back"
}
}
if err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) {
type row struct {
TransferID uint64 `gorm:"column:transfer_id"`
MovementNumber string `gorm:"column:movement_number"`
LocationName string `gorm:"column:location_name"`
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
FarmWarehouseName string `gorm:"column:farm_warehouse_name"`
ProductName string `gorm:"column:product_name"`
Qty float64 `gorm:"column:qty"`
}
needle := buildRunReasonMatcher(runID)
var dbRows []row
err := db.WithContext(ctx).
Table("stock_transfers st").
Select(`
st.id AS transfer_id,
st.movement_number AS movement_number,
COALESCE(loc.name, '') AS location_name,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS qty
`).
Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id").
Joins("JOIN warehouses wd ON wd.id = st.to_warehouse_id").
Joins("LEFT JOIN locations loc ON loc.id = COALESCE(ws.location_id, wd.location_id)").
Joins("JOIN stock_transfer_details std ON std.stock_transfer_id = st.id AND std.deleted_at IS NULL").
Joins("JOIN products p ON p.id = std.product_id").
Where("st.deleted_at IS NULL").
Where("st.reason LIKE ?", needle).
Order("st.id DESC, std.id ASC").
Scan(&dbRows).Error
if err != nil {
return nil, err
}
rows := make([]rollbackDetailRow, 0, len(dbRows))
for _, row := range dbRows {
rows = append(rows, rollbackDetailRow{
RunID: runID,
TransferID: row.TransferID,
MovementNumber: row.MovementNumber,
LocationName: row.LocationName,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseName: row.FarmWarehouseName,
ProductName: row.ProductName,
Qty: row.Qty,
})
}
return rows, nil
}
func applyTimingLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
if opts == nil {
return db
}
switch {
case opts.LocationID > 0:
return db.Where("pf.location_id = ?", opts.LocationID)
case opts.LocationName != "":
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
default:
return db
}
}
func applyLegacyStockLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
if opts == nil {
return db
}
switch {
case opts.LocationID > 0:
return db.Where("kw.location_id = ?", opts.LocationID)
case opts.LocationName != "":
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
default:
return db
}
}
func buildCutoverReason(runID, locationName string, cutoverDate time.Time) string {
locationName = strings.ReplaceAll(strings.TrimSpace(locationName), "|", "/")
return fmt.Sprintf("%s|run_id=%s|location=%s|cutover_date=%s", cutoverReasonPrefix, runID, locationName, cutoverDate.Format("2006-01-02"))
}
func buildRunReasonMatcher(runID string) string {
return fmt.Sprintf("%s|run_id=%s|%%", cutoverReasonPrefix, strings.TrimSpace(runID))
}
func buildRunID() string {
return fmt.Sprintf("egg-cutover-%s", time.Now().UTC().Format("20060102T150405.000000000Z"))
}
func normalizeDateOnly(value time.Time) time.Time {
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC)
}
func normalizeDatePtr(value *time.Time) *time.Time {
if value == nil {
return nil
}
normalized := normalizeDateOnly(*value)
return &normalized
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func summarizeApply(rows []migrationReportRow, groups []transferGroup, appliedGroups int) applySummary {
summary := applySummary{
GroupsPlanned: len(groups),
GroupsApplied: appliedGroups,
}
for _, row := range rows {
switch row.Status {
case "eligible":
summary.RowsPlanned++
case "applied":
summary.RowsPlanned++
summary.RowsApplied++
case "failed":
summary.RowsPlanned++
summary.RowsFailed++
case "skipped":
summary.RowsSkipped++
}
}
return summary
}
func flattenGroups(groups []transferGroup, fallback []migrationReportRow) []migrationReportRow {
if len(groups) == 0 {
return fallback
}
rows := make([]migrationReportRow, 0, len(fallback))
for _, group := range groups {
for _, row := range group.Rows {
rows = append(rows, *row)
}
}
for _, row := range fallback {
if row.Status == "skipped" {
rows = append(rows, row)
}
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].LocationName == rows[j].LocationName {
if rows[i].SourceWarehouseName == rows[j].SourceWarehouseName {
return rows[i].ProductName < rows[j].ProductName
}
return rows[i].SourceWarehouseName < rows[j].SourceWarehouseName
}
return rows[i].LocationName < rows[j].LocationName
})
return rows
}
func renderMigrationReport(mode string, rows []migrationReportRow, summary applySummary) {
if mode == outputModeJSON {
payload := map[string]any{
"rows": rows,
"summary": summary,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tLOCATION_STATUS\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER")
for _, row := range rows {
transferID := "-"
if row.TransferID != nil {
transferID = fmt.Sprintf("%d", *row.TransferID)
}
movementNumber := "-"
if row.MovementNumber != nil {
movementNumber = *row.MovementNumber
}
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\t%s\t%s\t%s\n",
row.RunID,
row.LocationName,
row.SourceWarehouseName,
derefString(row.FarmWarehouseName),
row.ProductName,
row.Qty,
row.LocationStatus,
row.Status,
row.Reason,
transferID,
movementNumber,
)
}
_ = w.Flush()
fmt.Printf("\nSummary: rows_planned=%d rows_applied=%d rows_skipped=%d rows_failed=%d groups_planned=%d groups_applied=%d\n",
summary.RowsPlanned, summary.RowsApplied, summary.RowsSkipped, summary.RowsFailed, summary.GroupsPlanned, summary.GroupsApplied)
}
func renderRollbackReport(mode string, rows []rollbackDetailRow) {
if mode == outputModeJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]any{"rows": rows})
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "RUN_ID\tTRANSFER_ID\tMOVEMENT_NUMBER\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tSTATUS\tREASON")
for _, row := range rows {
fmt.Fprintf(
w,
"%s\t%d\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\n",
row.RunID,
row.TransferID,
row.MovementNumber,
row.LocationName,
row.SourceWarehouseName,
row.FarmWarehouseName,
row.ProductName,
row.Qty,
row.Status,
row.Reason,
)
}
_ = w.Flush()
}
@@ -0,0 +1,251 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
)
func TestBuildMigrationPlanSkipsOverlapAndGroupsEligibleRows(t *testing.T) {
opts := &commandOptions{
RunID: "egg-cutover-test",
IncludeOverlap: false,
}
timings := map[uint]locationTiming{
16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"},
17: {LocationID: 17, LocationName: "Cijangkar", Status: "OVERLAP"},
}
farmID := uint(25)
farmName := "Gudang Farm Jamali"
rows := []legacyEggStockRow{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 101,
ProductID: 8,
ProductName: "Telur Utuh",
OnHandQty: 120,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 102,
ProductID: 9,
ProductName: "Telur Putih",
OnHandQty: 20,
},
{
LocationID: 17,
LocationName: "Cijangkar",
SourceWarehouseID: 51,
SourceWarehouseName: "Gudang Cijangkar 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 103,
ProductID: 10,
ProductName: "Telur Jumbo",
OnHandQty: 10,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
ProductWarehouseID: 104,
ProductID: 11,
ProductName: "Telur Papacal",
OnHandQty: 50,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 105,
ProductID: 12,
ProductName: "Telur Retak",
OnHandQty: 0,
},
}
reportRows, groups := buildMigrationPlan(opts, timings, rows)
if len(reportRows) != 5 {
t.Fatalf("expected 5 report rows, got %d", len(reportRows))
}
if len(groups) != 1 {
t.Fatalf("expected 1 eligible transfer group, got %d", len(groups))
}
if len(groups[0].Rows) != 2 {
t.Fatalf("expected 2 eligible products in the transfer group, got %d", len(groups[0].Rows))
}
statusByProduct := make(map[string]string, len(reportRows))
reasonByProduct := make(map[string]string, len(reportRows))
for _, row := range reportRows {
statusByProduct[row.ProductName] = row.Status
reasonByProduct[row.ProductName] = row.Reason
}
if statusByProduct["Telur Utuh"] != "eligible" || statusByProduct["Telur Putih"] != "eligible" {
t.Fatalf("expected Jamali egg rows to stay eligible, got statuses %+v", statusByProduct)
}
if reasonByProduct["Telur Jumbo"] != "overlap_location" {
t.Fatalf("expected overlap location skip, got %q", reasonByProduct["Telur Jumbo"])
}
if reasonByProduct["Telur Papacal"] != "missing_farm_warehouse" {
t.Fatalf("expected missing farm warehouse skip, got %q", reasonByProduct["Telur Papacal"])
}
if reasonByProduct["Telur Retak"] != "non_positive_qty" {
t.Fatalf("expected non positive qty skip, got %q", reasonByProduct["Telur Retak"])
}
}
func TestExecuteApplyBuildsTaggedSystemTransfersAndSummaries(t *testing.T) {
opts := &commandOptions{
RunID: "egg-cutover-apply",
CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
ActorID: 99,
}
groups := []transferGroup{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: 25,
FarmWarehouseName: "Gudang Farm Jamali",
Rows: []*migrationReportRow{
{ProductID: 8, ProductName: "Telur Utuh", Qty: 120},
{ProductID: 9, ProductName: "Telur Putih", Qty: 20},
},
},
{
LocationID: 18,
LocationName: "Tamansari",
SourceWarehouseID: 91,
SourceWarehouseName: "Gudang Tamansari 1",
FarmWarehouseID: 31,
FarmWarehouseName: "Gudang Farm Tamansari",
Rows: []*migrationReportRow{
{ProductID: 10, ProductName: "Telur Jumbo", Qty: 10},
},
},
}
executor := &fakeSystemTransferExecutor{
createResponses: []*entity.StockTransfer{
{Id: 1001, MovementNumber: "PND-LTI-1001"},
},
createErrors: []error{
nil,
errors.New("destination warehouse locked"),
},
}
summary, err := executeApply(context.Background(), executor, opts, groups)
if err != nil {
t.Fatalf("expected no fatal apply error, got %v", err)
}
if summary.GroupsPlanned != 2 || summary.GroupsApplied != 1 {
t.Fatalf("unexpected group summary: %+v", summary)
}
if summary.RowsApplied != 2 || summary.RowsFailed != 1 {
t.Fatalf("unexpected row summary: %+v", summary)
}
if len(executor.createRequests) != 2 {
t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests))
}
if !strings.Contains(executor.createRequests[0].TransferReason, "EGG_FARM_CUTOVER|run_id=egg-cutover-apply|location=Jamali|cutover_date=2026-04-07") {
t.Fatalf("unexpected transfer reason: %s", executor.createRequests[0].TransferReason)
}
if executor.createRequests[0].MovementNumber != "" {
t.Fatalf("apply path should let transfer service generate movement number, got %q", executor.createRequests[0].MovementNumber)
}
if groups[0].Rows[0].Status != "applied" || groups[0].Rows[1].Status != "applied" {
t.Fatalf("expected first group rows to be applied, got %+v", groups[0].Rows)
}
if groups[1].Rows[0].Status != "failed" {
t.Fatalf("expected second group row to fail, got %+v", groups[1].Rows[0])
}
if groups[0].Rows[0].TransferID == nil || *groups[0].Rows[0].TransferID != 1001 {
t.Fatalf("expected first row to keep created transfer id, got %+v", groups[0].Rows[0].TransferID)
}
}
func TestExecuteRollbackDeletesTransfersDescendingAndMarksFailures(t *testing.T) {
executor := &fakeSystemTransferExecutor{
deleteErrors: map[uint]error{
101: errors.New("already consumed downstream"),
},
}
rows := []rollbackDetailRow{
{TransferID: 100, ProductName: "Telur Utuh"},
{TransferID: 101, ProductName: "Telur Jumbo"},
{TransferID: 100, ProductName: "Telur Putih"},
}
err := executeRollback(context.Background(), executor, rows, 99)
if err == nil {
t.Fatal("expected rollback to return the first transfer error")
}
if err.Error() != "already consumed downstream" {
t.Fatalf("unexpected rollback error: %v", err)
}
if len(executor.deletedTransferIDs) != 2 {
t.Fatalf("expected 2 delete calls, got %d", len(executor.deletedTransferIDs))
}
if executor.deletedTransferIDs[0] != 101 || executor.deletedTransferIDs[1] != 100 {
t.Fatalf("expected delete order [101 100], got %v", executor.deletedTransferIDs)
}
if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" {
t.Fatalf("expected transfer 100 rows to be rolled back, got %+v", rows)
}
if rows[1].Status != "failed" {
t.Fatalf("expected transfer 101 row to fail, got %+v", rows[1])
}
}
type fakeSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer
createErrors []error
deletedTransferIDs []uint
deleteErrors map[uint]error
}
func (f *fakeSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) {
f.createRequests = append(f.createRequests, req)
idx := len(f.createRequests) - 1
if idx < len(f.createErrors) && f.createErrors[idx] != nil {
return nil, f.createErrors[idx]
}
if idx < len(f.createResponses) && f.createResponses[idx] != nil {
return f.createResponses[idx], nil
}
return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil
}
func (f *fakeSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
if f.deleteErrors == nil {
return nil
}
return f.deleteErrors[id]
}
@@ -0,0 +1,380 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
"gorm.io/gorm"
)
const metricEpsilon = 1e-9
type normalizeOptions struct {
Apply bool
RecordingID uint
ProjectFlockKandangID uint
From *time.Time
To *time.Time
BatchSize int
Limit int
}
type normalizeStats struct {
Processed int
Changed int
Updated int
Skipped int
Failed int
}
type recordingMetricRow struct {
ID uint `gorm:"column:id"`
ProjectFlockKandangID uint `gorm:"column:project_flock_kandangs_id"`
RecordDatetime time.Time `gorm:"column:record_datetime"`
HenHouse *float64 `gorm:"column:hen_house"`
EggMass *float64 `gorm:"column:egg_mass"`
}
func main() {
var (
apply bool
recordingID uint
projectFlockKandangID uint
fromRaw string
toRaw string
batchSize int
limit int
)
flag.BoolVar(&apply, "apply", false, "Apply update. If false, run as dry-run")
flag.UintVar(&recordingID, "recording-id", 0, "Target a single recording ID")
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Filter by project_flock_kandangs_id")
flag.StringVar(&fromRaw, "from", "", "Lower bound record_datetime (RFC3339 / YYYY-MM-DD)")
flag.StringVar(&toRaw, "to", "", "Upper bound record_datetime (RFC3339 / YYYY-MM-DD)")
flag.IntVar(&batchSize, "batch-size", 200, "Batch size when scanning recordings")
flag.IntVar(&limit, "limit", 0, "Max recordings to process (0 = no limit)")
flag.Parse()
if batchSize <= 0 {
log.Fatal("--batch-size must be > 0")
}
if limit < 0 {
log.Fatal("--limit cannot be negative")
}
from, err := parseTimeBound(strings.TrimSpace(fromRaw), false)
if err != nil {
log.Fatalf("invalid --from: %v", err)
}
to, err := parseTimeBound(strings.TrimSpace(toRaw), true)
if err != nil {
log.Fatalf("invalid --to: %v", err)
}
if from != nil && to != nil && to.Before(*from) {
log.Fatal("--to cannot be before --from")
}
opts := normalizeOptions{
Apply: apply,
RecordingID: recordingID,
ProjectFlockKandangID: projectFlockKandangID,
From: from,
To: to,
BatchSize: batchSize,
Limit: limit,
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
repo := recordingRepo.NewRecordingRepository(db)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("Filter recording_id: %s\n", displayUint(opts.RecordingID))
fmt.Printf("Filter project_flock_kandangs_id: %s\n", displayUint(opts.ProjectFlockKandangID))
fmt.Printf("Filter from: %s\n", displayTime(opts.From))
fmt.Printf("Filter to: %s\n", displayTime(opts.To))
fmt.Printf("Batch size: %d\n", opts.BatchSize)
fmt.Printf("Limit: %d\n\n", opts.Limit)
stats, err := normalizeRecordings(ctx, db, repo, opts)
if err != nil {
log.Fatalf("normalize failed: %v", err)
}
fmt.Println()
fmt.Printf(
"Summary: processed=%d changed=%d updated=%d skipped=%d failed=%d\n",
stats.Processed,
stats.Changed,
stats.Updated,
stats.Skipped,
stats.Failed,
)
if stats.Failed > 0 {
os.Exit(1)
}
}
func normalizeRecordings(
ctx context.Context,
db *gorm.DB,
repo recordingRepo.RecordingRepository,
opts normalizeOptions,
) (normalizeStats, error) {
stats := normalizeStats{}
lastID := uint(0)
initialChickCache := make(map[uint]float64)
for {
batchLimit := opts.BatchSize
if opts.Limit > 0 {
remaining := opts.Limit - stats.Processed
if remaining <= 0 {
break
}
if remaining < batchLimit {
batchLimit = remaining
}
}
rows, err := loadRecordingBatch(ctx, db, opts, lastID, batchLimit)
if err != nil {
return stats, err
}
if len(rows) == 0 {
break
}
for _, row := range rows {
stats.Processed++
lastID = row.ID
initialChick, ok := initialChickCache[row.ProjectFlockKandangID]
if !ok {
initialChick, err = repo.GetTotalChickinByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID)
if err != nil {
fmt.Printf("FAIL rec=%d error=getTotalChickinByProjectFlockKandang: %v\n", row.ID, err)
stats.Failed++
continue
}
initialChickCache[row.ProjectFlockKandangID] = initialChick
}
_, totalEggWeightGrams, err := repo.GetEggSummaryByRecording(db.WithContext(ctx), row.ID)
if err != nil {
fmt.Printf("FAIL rec=%d error=getEggSummaryByRecording: %v\n", row.ID, err)
stats.Failed++
continue
}
cumulativeEggQty, err := repo.GetCumulativeEggQtyByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID, row.RecordDatetime)
if err != nil {
fmt.Printf("FAIL rec=%d error=getCumulativeEggQtyByProjectFlockKandang: %v\n", row.ID, err)
stats.Failed++
continue
}
newHenHouse, newEggMass := computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams)
henHouseChanged := metricChanged(row.HenHouse, newHenHouse)
eggMassChanged := metricChanged(row.EggMass, newEggMass)
if !henHouseChanged && !eggMassChanged {
stats.Skipped++
continue
}
stats.Changed++
fmt.Printf(
"PLAN rec=%d pfk=%d at=%s hen_house:%s->%s egg_mass:%s->%s\n",
row.ID,
row.ProjectFlockKandangID,
row.RecordDatetime.UTC().Format(time.RFC3339),
displayFloat(row.HenHouse),
displayFloat(newHenHouse),
displayFloat(row.EggMass),
displayFloat(newEggMass),
)
if !opts.Apply {
continue
}
if err := updateRecordingMetrics(ctx, db, row.ID, newHenHouse, newEggMass); err != nil {
fmt.Printf("FAIL rec=%d error=updateRecordingMetrics: %v\n", row.ID, err)
stats.Failed++
continue
}
fmt.Printf(
"DONE rec=%d hen_house=%s egg_mass=%s\n",
row.ID,
displayFloat(newHenHouse),
displayFloat(newEggMass),
)
stats.Updated++
}
if opts.RecordingID > 0 {
break
}
}
return stats, nil
}
func loadRecordingBatch(
ctx context.Context,
db *gorm.DB,
opts normalizeOptions,
lastID uint,
limit int,
) ([]recordingMetricRow, error) {
query := db.WithContext(ctx).
Table("recordings").
Select("id, project_flock_kandangs_id, record_datetime, hen_house, egg_mass").
Where("recordings.deleted_at IS NULL")
if opts.RecordingID > 0 {
query = query.Where("recordings.id = ?", opts.RecordingID)
}
if opts.ProjectFlockKandangID > 0 {
query = query.Where("recordings.project_flock_kandangs_id = ?", opts.ProjectFlockKandangID)
}
if opts.From != nil {
query = query.Where("recordings.record_datetime >= ?", *opts.From)
}
if opts.To != nil {
query = query.Where("recordings.record_datetime <= ?", *opts.To)
}
if opts.RecordingID == 0 && lastID > 0 {
query = query.Where("recordings.id > ?", lastID)
}
var rows []recordingMetricRow
err := query.
Order("recordings.id ASC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams float64) (*float64, *float64) {
var henHouse *float64
if initialChick > 0 && cumulativeEggQty >= 0 {
value := cumulativeEggQty / initialChick
henHouse = &value
}
var eggMass *float64
if initialChick > 0 && totalEggWeightGrams > 0 {
value := totalEggWeightGrams / initialChick
eggMass = &value
}
return henHouse, eggMass
}
func updateRecordingMetrics(ctx context.Context, db *gorm.DB, recordingID uint, henHouse, eggMass *float64) error {
updates := map[string]any{}
if henHouse == nil {
updates["hen_house"] = gorm.Expr("NULL")
} else {
updates["hen_house"] = *henHouse
}
if eggMass == nil {
updates["egg_mass"] = gorm.Expr("NULL")
} else {
updates["egg_mass"] = *eggMass
}
return db.WithContext(ctx).
Table("recordings").
Where("id = ?", recordingID).
Updates(updates).Error
}
func metricChanged(oldValue, newValue *float64) bool {
if oldValue == nil && newValue == nil {
return false
}
if oldValue == nil || newValue == nil {
return true
}
return !nearlyEqual(*oldValue, *newValue)
}
func nearlyEqual(a, b float64) bool {
scale := math.Max(1, math.Max(math.Abs(a), math.Abs(b)))
return math.Abs(a-b) <= metricEpsilon*scale
}
func parseTimeBound(raw string, isUpper bool) (*time.Time, error) {
if raw == "" {
return nil, nil
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, raw)
if err != nil {
continue
}
if layout == "2006-01-02" {
if isUpper {
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
return &endOfDay, nil
}
startOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC)
return &startOfDay, nil
}
t := parsed.UTC()
return &t, nil
}
return nil, fmt.Errorf("unsupported format %q", raw)
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func displayFloat(v *float64) string {
if v == nil {
return "NULL"
}
return fmt.Sprintf("%.6f", *v)
}
func displayTime(v *time.Time) string {
if v == nil {
return "<nil>"
}
return v.UTC().Format(time.RFC3339)
}
func displayUint(v uint) string {
if v == 0 {
return "<all>"
}
return fmt.Sprintf("%d", v)
}
@@ -0,0 +1,282 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
const qtyEpsilon = 1e-6
const (
levelAll = 1
levelByProductName = 2
levelByProductWarehouse = 3
)
type reflowRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
CurrentQty float64 `gorm:"column:current_qty"`
SumTotalQty float64 `gorm:"column:sum_total_qty"`
SumAllocatedQty float64 `gorm:"column:sum_allocated_qty"`
ComputedQty float64 `gorm:"column:computed_qty"`
}
func main() {
var (
apply bool
level int
productName string
productWarehouseID uint
)
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.IntVar(&level, "level", levelAll, "CLI level: 1=all product_warehouse scope, 2=product name scope, 3=product_warehouse_id scope")
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
flag.Parse()
productName = strings.TrimSpace(productName)
if err := validateFlags(level, productName, productWarehouseID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
rows, err := loadReflowRows(ctx, db, level, productName, productWarehouseID)
if err != nil {
log.Fatalf("failed to calculate reflow qty: %v", err)
}
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
if productName != "" {
fmt.Printf("Filter product_name: %s\n", productName)
}
if productWarehouseID > 0 {
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
}
fmt.Printf("Targets found: %d\n\n", len(rows))
if len(rows) == 0 {
fmt.Println("No product warehouse found from purchase_items scope")
return
}
negativePlan := 0
for _, row := range rows {
if row.ComputedQty < 0 {
negativePlan++
}
fmt.Printf(
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f total_qty=%.3f allocated_qty=%.3f computed_qty=%.3f delta=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.SumTotalQty,
row.SumAllocatedQty,
row.ComputedQty,
row.ComputedQty-row.CurrentQty,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0 negative_plan=%d\n", len(rows), negativePlan)
return
}
updated := 0
skipped := 0
negativeUpdated := 0
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, row := range rows {
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
fmt.Printf(
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
row.ProductWarehouseID,
row.CurrentQty,
row.ComputedQty,
)
skipped++
continue
}
if err := tx.Table("product_warehouses").
Where("id = ?", row.ProductWarehouseID).
Update("qty", row.ComputedQty).Error; err != nil {
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
}
if row.ComputedQty < 0 {
negativeUpdated++
}
fmt.Printf(
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
)
updated++
}
return nil
})
if err != nil {
fmt.Println()
fmt.Printf(
"Summary: planned=%d updated=%d skipped=%d failed=1 negative_plan=%d negative_updated=%d\n",
len(rows),
updated,
skipped,
negativePlan,
negativeUpdated,
)
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Printf(
"Summary: planned=%d updated=%d skipped=%d failed=0 negative_plan=%d negative_updated=%d\n",
len(rows),
updated,
skipped,
negativePlan,
negativeUpdated,
)
}
func validateFlags(level int, productName string, productWarehouseID uint) error {
switch level {
case levelAll:
if productName != "" {
return errors.New("--product-name cannot be used on level 1")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 1")
}
case levelByProductName:
if productName == "" {
return errors.New("--product-name is required on level 2")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 2")
}
case levelByProductWarehouse:
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required on level 3")
}
if productName != "" {
return errors.New("--product-name cannot be used on level 3")
}
default:
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
}
return nil
}
func loadReflowRows(
ctx context.Context,
db *gorm.DB,
level int,
productName string,
productWarehouseID uint,
) ([]reflowRow, error) {
allocSub := db.WithContext(ctx).
Table("stock_allocations sa").
Select(`
sa.stockable_id,
COALESCE(SUM(sa.qty), 0) AS used_qty
`).
Where("sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.deleted_at IS NULL").
Group("sa.stockable_id")
calcSub := db.WithContext(ctx).
Table("purchase_items pi").
Select(`
pi.product_warehouse_id,
COALESCE(SUM(pi.total_qty), 0) AS sum_total_qty,
COALESCE(SUM(COALESCE(alloc.used_qty, 0)), 0) AS sum_allocated_qty,
COALESCE(SUM(COALESCE(pi.total_qty, 0) - COALESCE(alloc.used_qty, 0)), 0) AS computed_qty
`).
Joins("LEFT JOIN (?) alloc ON alloc.stockable_id = pi.id", allocSub).
Where("pi.product_warehouse_id IS NOT NULL").
Group("pi.product_warehouse_id")
query := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
calc.sum_total_qty,
calc.sum_allocated_qty,
calc.computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN (?) calc ON calc.product_warehouse_id = pw.id", calcSub).
Order("pw.id ASC")
switch level {
case levelByProductName:
query = query.Where("LOWER(p.name) = LOWER(?)", productName)
case levelByProductWarehouse:
query = query.Where("pw.id = ?", productWarehouseID)
}
rows := make([]reflowRow, 0)
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func levelLabel(level int) string {
switch level {
case levelAll:
return "all product_warehouse from purchase_items"
case levelByProductName:
return "specific product name"
case levelByProductWarehouse:
return "specific product_warehouse_id"
default:
return "unknown"
}
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}
+31
View File
@@ -0,0 +1,31 @@
# Farm Stock Attribution Design Note
## Goal
Allow farm-level physical stock to be used directly by kandang-level operations without forcing transfers, while keeping kandang attribution, FIFO-v2 compatibility, traceability, and HPP/COGS intact.
## Core Model
- Physical stock stays on the real `product_warehouse_id` that was consumed or received.
- Kandang attribution comes from the transaction or allocation path, not from `product_warehouses.project_flock_kandang_id`.
- Existing kandang-bound warehouses remain valid for historical and current kandang-only flows.
- Shared farm warehouses must stay shareable; application code must stop silently converting them into kandang-owned warehouses.
## Attribution Rules
- `recording_stocks`: consumer kandang is the parent `recordings.project_flock_kandangs_id`; physical stock source remains `recording_stocks.product_warehouse_id`.
- `recording_depletions`: source kandang is the recording kandang and is stored explicitly for compatibility; physical source remains `source_product_warehouse_id`, destination stock remains `product_warehouse_id`.
- `recording_eggs`: producer kandang is the recording kandang and is stored explicitly for compatibility; physical stock remains `product_warehouse_id`, which may be a farm warehouse.
- `marketing_delivery_products`: outbound kandang attribution comes from active `stock_allocations` to `PROJECT_FLOCK_POPULATION`, `RECORDING_DEPLETION`, or `RECORDING_EGG`, with product-warehouse kandang ownership only as a fallback for historical/non-FIFO rows.
## Reporting and HPP
- Feed and OVK cost attribution should continue to follow recording-level consumption plus FIFO allocations to incoming stock.
- Egg and live-bird sales attribution should be derived from `stock_allocations` back to the originating kandang transactions or populations.
- Queries that filter or group by kandang must use explicit transaction attribution or FIFO allocation provenance, not warehouse ownership, when pooled farm stock is involved.
## Live-Data Safety
- Schema changes are additive and nullable.
- Historical rows are backfilled only when attribution is deterministic from existing rows.
- No FIFO-v2 route-rule behavior is changed unless the current code is only resyncing or constraining allocation metadata around already-created FIFO allocations.
+286
View File
@@ -0,0 +1,286 @@
# Runbook Cutover Stok Telur Historis Kandang ke Gudang Farm
## Tujuan
Runbook ini dipakai untuk memindahkan **stok telur historis yang masih on-hand di gudang kandang** ke **gudang farm** secara aman, audit-able, dan reversible.
Cutover dilakukan dengan **transfer stok eksplisit**, bukan dengan mengubah `recording_eggs.product_warehouse_id` historis.
## Scope
Runbook ini hanya untuk:
- stok telur historis kandang-level yang masih punya saldo on-hand
- lokasi yang masuk kategori **clean cutover**
- lokasi yang sudah punya gudang farm
Runbook ini **tidak** dipakai untuk:
- lokasi overlap seperti `Cijangkar`
- koreksi histori `recording_eggs`
- migrasi stok non-telur
## Kebijakan yang Dikunci
- Sumber qty yang dipindah adalah **`product_warehouses.qty` saat cutover**
- Perintah dijalankan **per lokasi**
- Wajib mulai dari `dry-run`
- `--apply` hanya boleh dijalankan setelah review dry-run dan SQL checklist
- Lokasi overlap tidak ikut otomatis kecuali ada approval khusus dan `--include-overlap`
- Rollback hanya boleh dilakukan jika transfer hasil cutover belum dipakai transaksi turunan
## Lokasi Fase 1
Lokasi yang boleh dieksekusi pada fase pertama:
- `Jamali`
- `Cantilan`
- `Darawati`
- `Tamansari`
Lokasi yang harus ditahan:
- `Cijangkar`
## Prasyarat
Sebelum eksekusi, pastikan:
- backend sudah ter-deploy dengan command [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go)
- reusable transfer core sudah ikut ter-deploy:
- [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go)
- [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go)
- migrasi farm stock attribution sebelumnya sudah terpasang
- akses database target sudah tersedia
- environment target memakai SSL bila RDS mewajibkan, contoh:
- `DB_SSLMODE=require`
## Catatan Output Command
Mode `--output table` adalah mode operasional yang direkomendasikan.
Mode `--output json` bisa dipakai, tetapi pada environment saat ini output JSON masih dapat didahului log bootstrap aplikasi atau SQL logger. Untuk review manual gunakan `table`. Untuk parsing otomatis, filter payload mulai dari `{`.
## Format Command
### Dry-run
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--output table
```
### Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--cutover-date 2026-04-07 \
--apply \
--output table
```
### Rollback Preview
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--output table
```
### Rollback Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--apply \
--output table
```
## Arti `run_id`
Setiap dry-run/apply menghasilkan `run_id`, misalnya:
```text
egg-cutover-20260407T130344.220407000Z
```
`run_id` ini wajib disimpan karena dipakai untuk:
- audit hasil cutover
- query verifikasi
- rollback
## Prosedur Eksekusi Per Lokasi
### 1. Persiapan
Tentukan:
- `location_name`
- `cutover_date`
- operator yang bertanggung jawab
Contoh:
- lokasi: `Jamali`
- cutover date: `2026-04-07`
### 2. Jalankan Dry-run
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--output table
```
Yang harus dicek pada hasil dry-run:
- status lokasi `CLEAN_CUTOVER`
- semua baris yang akan dipindah punya `status=eligible`
- gudang tujuan adalah gudang farm lokasi tersebut
- qty yang dipindah masuk akal dan sesuai saldo on-hand aktual
- tidak ada `missing_farm_warehouse`
- tidak ada `overlap_location`
### 3. Jalankan Checklist SQL Before
Gunakan file:
- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
Minimal pastikan:
- lokasi memang clean cutover
- stok telur kandang positif masih ada
- gudang farm ada
- belum ada transfer `EGG_FARM_CUTOVER` aktif untuk lokasi yang sama pada run yang akan dipakai
### 4. Simpan Evidence Sebelum Apply
Simpan:
- output dry-run
- hasil query before
- nama operator
- waktu eksekusi
Disarankan simpan dalam ticket / change record.
### 5. Jalankan Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--cutover-date 2026-04-07 \
--apply \
--output table
```
Setelah apply, simpan:
- `run_id`
- seluruh row dengan `transfer_id`
- movement number yang terbentuk
### 6. Jalankan Checklist SQL After
Masih menggunakan file:
- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
Minimal pastikan:
- transfer header/detail tercatat untuk `run_id`
- qty source berkurang sesuai transfer
- qty farm bertambah sesuai transfer
- total gabungan source+dest per produk per lokasi tetap sama
- stok eligible tidak lagi tersedia di gudang kandang
- stok telur sekarang tersedia di gudang farm
### 7. Smoke Test UI
Lakukan minimal:
- buka product stock farm untuk lokasi tersebut
- pastikan produk telur hasil migrasi muncul
- buat SO farm-level dan pastikan opsi produk telur tersedia
- pastikan recording telur baru setelah cutover tetap langsung masuk ke gudang farm
### 8. Tutup Eksekusi
Catat hasil akhir:
- sukses/gagal
- `run_id`
- lokasi
- tanggal cutover
- operator
- link ke evidence SQL/UI
## Kriteria Go / No-Go
### Boleh lanjut apply bila:
- dry-run menunjukkan hanya row yang memang expected
- lokasi `CLEAN_CUTOVER`
- gudang farm valid
- query before menunjukkan tidak ada anomaly blocking
### Wajib stop bila:
- lokasi terdeteksi `OVERLAP`
- ada qty aneh atau tidak sesuai data lapangan
- gudang farm tidak ada
- ada transfer lama serupa yang belum direkonsiliasi
- setelah apply terjadi selisih total source+dest
## Rollback Runbook
### Kapan rollback boleh dilakukan
Rollback boleh jika:
- transfer hasil cutover belum dipakai transaksi turunan
- verifikasi after menunjukkan issue yang membuat hasil cutover tidak dapat diterima
### Langkah rollback
1. Preview rollback:
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--output table
```
2. Jalankan query rollback readiness pada file audit/helper SQL.
3. Jika aman, apply rollback:
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--apply \
--output table
```
4. Jalankan ulang query verifikasi after rollback.
### Kapan rollback akan gagal by design
Rollback memang harus gagal jika:
- transfer hasil cutover sudah dipakai sales/recording/transaksi turunan
- sudah ada `stock_allocations` consume aktif terhadap `STOCK_TRANSFER_IN`
## Urutan Rollout yang Direkomendasikan
### Dev
1. Dry-run per lokasi
2. Review SQL before
3. Apply per lokasi
4. SQL after
5. Smoke UI
6. Simpan `run_id`
### Production
1. Freeze operasional lokasi target bila perlu
2. Dry-run
3. Review by dev + ops + finance/stock owner
4. Apply
5. SQL after
6. Smoke UI
7. Release lokasi berikutnya
## Referensi
- Command cutover: [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go)
- Test command: [main_test.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main_test.go)
- Core reusable transfer: [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go)
- Transfer service refactor: [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go)
- Checklist SQL: [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
- Helper query audit: [legacy_egg_cutover_audit_queries.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_audit_queries.sql)
+45
View File
@@ -0,0 +1,45 @@
ID;Kategori;Area;Judul;Tipe;Prioritas;Setup/Precondition;Langkah Uji;Hasil yang Diharapkan
TC-A01;Migrasi dan Keamanan Data;Database;Migrasi aman pada DB tidak kosong;Integration;High;Gunakan snapshot DB staging yang sudah berisi recording, depletion, telur, penjualan, dan closing.;1. Jalankan migrasi 20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql. 2. Inspect schema hasil migrasi.;Kolom recording_depletions.source_project_flock_kandang_id dan recording_eggs.project_flock_kandang_id tersedia dan nullable, index dan FK tersedia, tidak ada data historis yang terhapus atau berubah destruktif.
TC-A02;Migrasi dan Keamanan Data;Database;Backfill deterministik berjalan;Integration;High;Ada data historis recording dengan recordings.project_flock_kandangs_id yang valid.;1. Query recording_depletions dan recording_eggs yang lama. 2. Bandingkan dengan kandang pada parent recording.;source_project_flock_kandang_id dan project_flock_kandang_id terisi sama dengan kandang parent recording untuk row yang sebelumnya null.
TC-A03;Migrasi dan Keamanan Data;Reporting;Report historis kandang-only tidak berubah;Regression;High;Gunakan snapshot yang hanya memiliki data stok historis milik kandang, tanpa pooled stock farm-level.;1. Jalankan closing/report/HPP sebelum deploy. 2. Jalankan lagi sesudah deploy pada snapshot yang sama. 3. Bandingkan hasil.;Total dan hasil report tetap sama untuk skenario historis kandang-only.
TC-B01;Purchase dan Warehouse;Purchase;Purchase pakan langsung ke gudang farm;UAT;High;Tersedia PO atau purchase request untuk produk Pakan Starter.;1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase.;Stok masuk ke product_warehouse level farm, tidak perlu transfer paksa ke kandang, FIFO/HPP purchase tetap benar.
TC-B02;Purchase dan Warehouse;Purchase;Purchase pakan langsung ke gudang kandang;Regression;High;Tersedia PO atau purchase request untuk produk Pakan Starter.;1. Buat purchase ke Gudang Kandang A1. 2. Approve dan receive purchase.;Stok masuk ke gudang kandang dan perilaku tetap sama seperti flow lama.
TC-B03;Purchase dan Warehouse;Purchase;Purchase OVK langsung ke gudang farm;UAT;High;Tersedia PO atau purchase request untuk produk OVK A.;1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase.;Stok OVK masuk ke gudang farm dan bisa dipakai kemudian pada recording.
TC-B04;Purchase dan Warehouse;Product Warehouse;Gudang farm shared tidak diubah diam-diam menjadi milik kandang;Regression;High;Sudah ada row product_warehouse level farm untuk Pakan Starter di Gudang Farm A.;1. Trigger flow yang memanggil ensure/find product warehouse untuk produk yang sama. 2. Inspect row existing.;Row farm-level tetap farm-level, project_flock_kandang_id tidak dibackfill diam-diam, row khusus kandang dibuat terpisah bila memang diperlukan.
TC-C01;Recording Stock Consumption;Recording;Recording kandang memakai pakan dari gudang kandang;Regression;High;Stok pakan tersedia di Gudang Kandang A1.;1. Buka recording untuk Kandang A1. 2. Pilih pakan dari gudang kandang. 3. Submit dan approve.;Recording berhasil, stok keluar dari product_warehouse kandang, atribusi kandang tetap A1, HPP pemakaian muncul di closing/HPP A1.
TC-C02;Recording Stock Consumption;Recording;Recording kandang memakai pakan dari gudang farm;UAT;High;Stok pakan hanya tersedia di Gudang Farm A.;1. Buka recording untuk Kandang A1. 2. Pilih stok pakan farm-level. 3. Submit dan approve.;Recording berhasil tanpa transfer ke kandang, stok fisik berkurang dari gudang farm, usage/HPP tetap teratribusi ke Kandang A1, closing farm dan kandang tetap bisa dihitung.
TC-C03;Recording Stock Consumption;Recording;Recording kandang memakai OVK dari gudang farm;UAT;High;Stok OVK hanya tersedia di Gudang Farm A.;1. Buka recording untuk Kandang A1. 2. Pilih stok OVK farm-level. 3. Submit dan approve.;Stok OVK keluar dari gudang farm dan biaya pemakaian teratribusi ke kandang yang dipilih.
TC-C04;Recording Stock Consumption;Frontend Recording;Selector recording menampilkan opsi stok farm dan kandang dengan jelas;UI Regression;Medium;Produk yang sama tersedia di Gudang Farm A dan Gudang Kandang A1.;1. Buka form recording untuk A1. 2. Buka selector pakan.;Kedua opsi terlihat, label membedakan gudang atau scope dengan jelas, farm stock tidak tersembunyi secara salah.
TC-C05;Recording Stock Consumption;Recording;Recording A1 tidak boleh memakai stok kandang A2;Negative;High;Pakan Starter tersedia di Gudang Kandang A2.;1. Buka recording untuk A1. 2. Periksa opsi stok yang bisa dipilih.;Opsi Gudang Kandang A2 tidak bisa dipilih, stok farm tetap bisa dipilih.
TC-C06;Recording Stock Consumption;Recording;Perilaku pending stock dan usage lama tetap berjalan;Regression;Medium;Tidak ada setup khusus selain data recording yang valid.;1. Buat usage stock. 2. Buka kembali halaman edit dan detail.;Tampilan dan perhitungan pending atau usage tetap benar, tidak ada regresi pada route FIFO-v2.
TC-D01;Recording Telur dan Atribusi;Recording;Recording telur ke gudang kandang tetap berjalan;Regression;High;Kandang A1 aktif dan gudang telur kandang tersedia.;1. Record telur untuk A1 ke Gudang Kandang A1. 2. Approve.;Stok telur di gudang kandang bertambah dan asal kandang tetap A1.
TC-D02;Recording Telur dan Atribusi;Recording;Recording telur di kandang menyimpan stok ke gudang farm;UAT;High;Egg product warehouse tersedia di Gudang Farm A.;1. Record telur untuk A1. 2. Pilih Gudang Farm A sebagai gudang telur. 3. Submit dan approve.;Stok telur fisik masuk ke gudang farm, recording_eggs.project_flock_kandang_id bernilai A1, tidak ada transfer paksa ke kandang.
TC-D03;Recording Telur dan Atribusi;Reporting;Stok telur pooled di farm tetap punya jejak asal kandang;Integration;High;A1 record 100 telur ke gudang farm dan A2 record 150 telur ke gudang farm yang sama.;1. Inspect row telur yang tersimpan. 2. Inspect hasil costing atau report setelahnya.;Stok fisik pooled di gudang farm, tetapi asal kandang tetap bisa dibedakan per row atau allocation, HPP per kandang tetap dapat dihitung.
TC-D04;Recording Telur dan Atribusi;Recording Detail;Known gap pada detail recording dipahami;Known Limitation;Low;Sudah menjalankan TC-D02.;1. Buka detail recording setelah transaksi telur ke gudang farm.;Logika bisnis tetap berjalan, tetapi detail API atau UI mungkin belum menampilkan egg-origin secara eksplisit karena detail DTO belum diperluas.
TC-E01;Depletion dan Atribusi Populasi;Recording;Depletion dari gudang ayam milik kandang normal;Regression;High;A1 memiliki populasi ayam di gudang kandang.;1. Buat depletion. 2. Approve.;Depletion berhasil, alokasi populasi ter-resolve ke A1, HPP atau usage tetap benar.
TC-E02;Depletion dan Atribusi Populasi;Recording;Depletion dari sumber ayam fisik farm-level dengan source kandang A1;UAT;High;Stok ayam secara fisik ada di gudang farm dan punya jejak sumber ke A1.;1. Buat depletion untuk A1. 2. Gunakan path source atau farm-level yang didukung backend. 3. Approve.;source_product_warehouse_id menunjuk ke sumber fisik yang benar, source_project_flock_kandang_id bernilai A1, alokasi populasi berhasil tanpa mengasumsikan gudang fisik milik A1.
TC-E03;Depletion dan Atribusi Populasi;Recording;Depletion gagal bila sumber populasi tidak dapat diatribusikan;Negative;High;Buat kasus stok ayam farm-level tanpa source kandang yang valid.;1. Coba approve depletion.;Backend menolak dengan error yang jelas dan tidak ada silent misattribution.
TC-F01;Marketing dan Penjualan;Sales Order;Sales order dari gudang kandang tetap berjalan;Regression;High;Stok produk tersedia di Gudang Kandang A1.;1. Buat SO dari Gudang Kandang A1. 2. Lakukan delivery.;Perilaku lama tetap berjalan normal.
TC-F02;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk telur;UAT;High;Stok telur farm-level tersedia dan berasal dari A1.;1. Buat SO menggunakan Gudang Farm A. 2. Lakukan delivery.;SO dan DO berhasil, stok fisik berkurang dari gudang farm, HPP dan COGS telur tetap teratribusi ke kandang penghasil melalui allocation.
TC-F03;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk telur pooled A1 dan A2;Integration;High;Stok telur pooled tersedia di gudang farm dari A1 dan A2.;1. Buat penjualan. 2. Lakukan delivery. 3. Inspect closing atau report.;Stok fisik berkurang sekali dari gudang farm, revenue dan HPP terbagi benar ke A1 dan A2, tidak bergantung pada pw.project_flock_kandang_id.
TC-F04;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk ayam atau culling;UAT;High;Stok ayam atau culling farm-level tersedia dengan jejak sumber dari A1 dan A2.;1. Buat SO dari gudang farm. 2. Buat DO dan approve.;allocatePopulationForMarketingDelivery menurunkan atribusi kandang dari source groups atau allocation, tidak gagal karena gudang jual tidak punya project_flock_kandang_id, HPP dan COGS teratribusi ke kandang sumber.
TC-F05;Marketing dan Penjualan;Frontend Marketing;UI sales menampilkan semantik Gudang Fisik;UI Regression;Medium;Tidak ada setup khusus selain akses ke form SO.;1. Buka form SO. 2. Periksa label selector gudang dan label tabel produk.;UI menggunakan label Gudang Fisik, bukan Kandang yang menyesatkan, dan label produk memuat detail produk serta gudang atau scope.
TC-F06;Marketing dan Penjualan;Delivery Order;Layar delivery order tetap kompatibel;Regression;Medium;Sudah ada SO dari gudang farm.;1. Lakukan delivery untuk SO farm-level. 2. Periksa tabel dan detail DO.;Tidak ada masalah payload, gudang fisik tampil dengan benar, dan tidak ada kebingungan akibat wording lama berbasis kandang.
TC-G01;Report, Closing, dan HPP;Daily Marketing Report;Daily marketing report untuk penjualan telur farm-level;UAT;Medium;Sudah menjalankan TC-F02.;1. Jalankan daily marketing report. 2. Uji export.;Row muncul pada gudang fisik yang benar, report tidak menyiratkan gudang sama dengan kandang, export berjalan.
TC-G02;Report, Closing, dan HPP;Closing Sales;Closing sales untuk penjualan pooled farm-level;UAT;High;Ada penjualan pooled telur atau ayam dari gudang farm.;1. Buka closing sales.;Penjualan bisa tampil teratribusi per kandang, label menunjukkan Kandang Atribusi, HPP dan revenue tetap benar secara matematis.
TC-G03;Report, Closing, dan HPP;HPP per Kandang;HPP per kandang mencakup konsumsi pakan atau OVK dari gudang farm;UAT;High;A1 sudah memakai pakan atau OVK dari gudang farm.;1. Jalankan report HPP per kandang.;Biaya usage muncul di A1 dan tidak hilang walaupun gudang fisiknya level farm.
TC-G04;Report, Closing, dan HPP;Closing Sapronak;Outgoing sapronak menampilkan gudang fisik dengan benar;UI Regression;Medium;Ada data outgoing sapronak yang valid.;1. Buka tabel closing outgoing sapronak.;Header jelas menunjukkan Gudang Asal (Fisik) dan Gudang Tujuan (Fisik).
TC-G05;Report, Closing, dan HPP;Compatibility;Data historis kandang-owned dan pooled data baru dapat coexist;Regression;High;Dalam satu date range ada transaksi lama kandang-owned dan transaksi baru pooled farm-level.;1. Jalankan closing. 2. Jalankan report. 3. Jalankan HPP.;Kedua jenis data diproses dengan benar, tidak ada double count dan tidak ada atribusi yang hilang.
TC-H01;FIFO-v2 dan Integritas Allocation;FIFO-v2;Kontrak FIFO-v2 tidak berubah;Integration;High;Gunakan data uji yang mencakup recording stock, depletion, egg, dan marketing.;1. Verifikasi route FIFO untuk RECORDING_STOCK_OUT, RECORDING_DEPLETION_OUT, RECORDING_DEPLETION_IN, RECORDING_EGG_IN, dan MARKETING_OUT. 2. Bandingkan dengan RFC.md dan seed config FIFO-v2.;Tidak ada perubahan semantik route yang tidak disengaja.
TC-H02;FIFO-v2 dan Integritas Allocation;Stock Allocation;Stock allocation tetap konsisten untuk pakan dari gudang farm;Integration;High;Sudah menjalankan TC-C02.;1. Inspect stock_allocations setelah transaksi.;Allocation consume terbentuk dengan benar dan tidak ada row allocation yatim atau rusak.
TC-H03;FIFO-v2 dan Integritas Allocation;Stock Allocation;Stock allocation tetap konsisten untuk penjualan telur pooled;Integration;High;Sudah menjalankan TC-F03.;1. Inspect stock_allocations. 2. Inspect row atribusi turunannya.;Allocation mendukung atribusi HPP kembali ke kandang sumber.
TC-H04;FIFO-v2 dan Integritas Allocation;Population Allocation;Population allocation tetap konsisten untuk penjualan ayam pooled;Integration;High;Sudah menjalankan TC-F04.;1. Inspect population allocations.;Penggunaan kandang sumber teralokasi dengan benar dan tidak fallback ke atribusi null saat source tersedia.
TC-I01;Negative dan Guard Cases;Recording;Recording dari stok farm-level dengan qty tidak cukup;Negative;High;Stok farm-level tersedia tetapi qty lebih kecil dari pemakaian yang diinput.;1. Buat recording dengan qty melebihi stok. 2. Submit atau approve.;Muncul validation atau business error dan tidak ada korupsi parsial.
TC-I02;Negative dan Guard Cases;Marketing;Marketing dari stok farm-level dengan qty tidak cukup;Negative;High;Stok farm-level tersedia tetapi qty lebih kecil dari qty penjualan.;1. Buat SO atau DO dengan qty melebihi stok. 2. Submit atau approve.;Delivery atau approval diblok dan stok tetap konsisten.
TC-I03;Negative dan Guard Cases;Frontend Selector;Opsi produk sama di gudang berbeda tidak salah terpilih;UI Regression;Medium;Produk yang sama tersedia di gudang farm dan gudang kandang.;1. Pilih masing-masing opsi secara eksplisit di UI. 2. Save. 3. Buka kembali edit atau detail.;Opsi yang terpilih jelas dan tetap stabil setelah save atau edit.
TC-I04;Negative dan Guard Cases;Product Warehouse;Row gudang shared tidak diatribusikan ulang oleh flow maintenance;Regression;High;Ada row shared farm warehouse yang sudah aktif.;1. Jalankan flow yang menyentuh logic ensure/find product warehouse. 2. Cek ulang row farm shared.;Tidak ada mutasi diam-diam pada project_flock_kandang_id.
TC-J01;Regression Frontend dan UX;Recording Form;Form recording menampilkan opsi stok farm dan kandang hanya dalam scope farm yang sama;UI Regression;Medium;Ada stok di gudang farm, gudang kandang saat ini, dan gudang kandang lain.;1. Buka form recording untuk kandang tertentu. 2. Periksa opsi stock selector.;Gudang farm dan gudang kandang saat ini terlihat, gudang kandang lain tersembunyi.
TC-J02;Regression Frontend dan UX;Recording Form;Selector recording telur mengizinkan gudang farm;UI Regression;Medium;Egg warehouse tersedia di gudang farm.;1. Buka form recording telur. 2. Buka selector tujuan telur.;Gudang farm terlihat sebagai opsi tujuan telur.
TC-J03;Regression Frontend dan UX;Sales Form;Form sales memakai semantik gudang secara konsisten;UI Regression;Medium;Akses ke halaman marketing tersedia.;1. Buka form sales. 2. Periksa label selector dan summary table.;Label menggunakan Gudang Fisik secara konsisten dan tidak ada wording Kandang yang menyesatkan untuk stok fisik.
TC-J04;Regression Frontend dan UX;Marketing Modal;Modal list marketing menampilkan label gudang fisik;UI Regression;Low;Akses ke modal product list tersedia.;1. Buka modal product list di marketing.;Kolom menampilkan label Gudang Fisik.
TC-K01;Known Limitation;Recording Detail;Detail recording belum menampilkan source atau origin attribution baru;Known Limitation;Low;Sudah ada recording telur farm-level dan depletion dengan source attribution.;1. Buat transaksi. 2. Buka detail recording.;Transaksi berjalan dan atribusi tersimpan di DB, tetapi detail API atau UI mungkin belum menampilkan field source atau origin tersebut
1 ID Kategori Area Judul Tipe Prioritas Setup/Precondition Langkah Uji Hasil yang Diharapkan
2 TC-A01 Migrasi dan Keamanan Data Database Migrasi aman pada DB tidak kosong Integration High Gunakan snapshot DB staging yang sudah berisi recording, depletion, telur, penjualan, dan closing. 1. Jalankan migrasi 20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql. 2. Inspect schema hasil migrasi. Kolom recording_depletions.source_project_flock_kandang_id dan recording_eggs.project_flock_kandang_id tersedia dan nullable, index dan FK tersedia, tidak ada data historis yang terhapus atau berubah destruktif.
3 TC-A02 Migrasi dan Keamanan Data Database Backfill deterministik berjalan Integration High Ada data historis recording dengan recordings.project_flock_kandangs_id yang valid. 1. Query recording_depletions dan recording_eggs yang lama. 2. Bandingkan dengan kandang pada parent recording. source_project_flock_kandang_id dan project_flock_kandang_id terisi sama dengan kandang parent recording untuk row yang sebelumnya null.
4 TC-A03 Migrasi dan Keamanan Data Reporting Report historis kandang-only tidak berubah Regression High Gunakan snapshot yang hanya memiliki data stok historis milik kandang, tanpa pooled stock farm-level. 1. Jalankan closing/report/HPP sebelum deploy. 2. Jalankan lagi sesudah deploy pada snapshot yang sama. 3. Bandingkan hasil. Total dan hasil report tetap sama untuk skenario historis kandang-only.
5 TC-B01 Purchase dan Warehouse Purchase Purchase pakan langsung ke gudang farm UAT High Tersedia PO atau purchase request untuk produk Pakan Starter. 1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase. Stok masuk ke product_warehouse level farm, tidak perlu transfer paksa ke kandang, FIFO/HPP purchase tetap benar.
6 TC-B02 Purchase dan Warehouse Purchase Purchase pakan langsung ke gudang kandang Regression High Tersedia PO atau purchase request untuk produk Pakan Starter. 1. Buat purchase ke Gudang Kandang A1. 2. Approve dan receive purchase. Stok masuk ke gudang kandang dan perilaku tetap sama seperti flow lama.
7 TC-B03 Purchase dan Warehouse Purchase Purchase OVK langsung ke gudang farm UAT High Tersedia PO atau purchase request untuk produk OVK A. 1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase. Stok OVK masuk ke gudang farm dan bisa dipakai kemudian pada recording.
8 TC-B04 Purchase dan Warehouse Product Warehouse Gudang farm shared tidak diubah diam-diam menjadi milik kandang Regression High Sudah ada row product_warehouse level farm untuk Pakan Starter di Gudang Farm A. 1. Trigger flow yang memanggil ensure/find product warehouse untuk produk yang sama. 2. Inspect row existing. Row farm-level tetap farm-level, project_flock_kandang_id tidak dibackfill diam-diam, row khusus kandang dibuat terpisah bila memang diperlukan.
9 TC-C01 Recording Stock Consumption Recording Recording kandang memakai pakan dari gudang kandang Regression High Stok pakan tersedia di Gudang Kandang A1. 1. Buka recording untuk Kandang A1. 2. Pilih pakan dari gudang kandang. 3. Submit dan approve. Recording berhasil, stok keluar dari product_warehouse kandang, atribusi kandang tetap A1, HPP pemakaian muncul di closing/HPP A1.
10 TC-C02 Recording Stock Consumption Recording Recording kandang memakai pakan dari gudang farm UAT High Stok pakan hanya tersedia di Gudang Farm A. 1. Buka recording untuk Kandang A1. 2. Pilih stok pakan farm-level. 3. Submit dan approve. Recording berhasil tanpa transfer ke kandang, stok fisik berkurang dari gudang farm, usage/HPP tetap teratribusi ke Kandang A1, closing farm dan kandang tetap bisa dihitung.
11 TC-C03 Recording Stock Consumption Recording Recording kandang memakai OVK dari gudang farm UAT High Stok OVK hanya tersedia di Gudang Farm A. 1. Buka recording untuk Kandang A1. 2. Pilih stok OVK farm-level. 3. Submit dan approve. Stok OVK keluar dari gudang farm dan biaya pemakaian teratribusi ke kandang yang dipilih.
12 TC-C04 Recording Stock Consumption Frontend Recording Selector recording menampilkan opsi stok farm dan kandang dengan jelas UI Regression Medium Produk yang sama tersedia di Gudang Farm A dan Gudang Kandang A1. 1. Buka form recording untuk A1. 2. Buka selector pakan. Kedua opsi terlihat, label membedakan gudang atau scope dengan jelas, farm stock tidak tersembunyi secara salah.
13 TC-C05 Recording Stock Consumption Recording Recording A1 tidak boleh memakai stok kandang A2 Negative High Pakan Starter tersedia di Gudang Kandang A2. 1. Buka recording untuk A1. 2. Periksa opsi stok yang bisa dipilih. Opsi Gudang Kandang A2 tidak bisa dipilih, stok farm tetap bisa dipilih.
14 TC-C06 Recording Stock Consumption Recording Perilaku pending stock dan usage lama tetap berjalan Regression Medium Tidak ada setup khusus selain data recording yang valid. 1. Buat usage stock. 2. Buka kembali halaman edit dan detail. Tampilan dan perhitungan pending atau usage tetap benar, tidak ada regresi pada route FIFO-v2.
15 TC-D01 Recording Telur dan Atribusi Recording Recording telur ke gudang kandang tetap berjalan Regression High Kandang A1 aktif dan gudang telur kandang tersedia. 1. Record telur untuk A1 ke Gudang Kandang A1. 2. Approve. Stok telur di gudang kandang bertambah dan asal kandang tetap A1.
16 TC-D02 Recording Telur dan Atribusi Recording Recording telur di kandang menyimpan stok ke gudang farm UAT High Egg product warehouse tersedia di Gudang Farm A. 1. Record telur untuk A1. 2. Pilih Gudang Farm A sebagai gudang telur. 3. Submit dan approve. Stok telur fisik masuk ke gudang farm, recording_eggs.project_flock_kandang_id bernilai A1, tidak ada transfer paksa ke kandang.
17 TC-D03 Recording Telur dan Atribusi Reporting Stok telur pooled di farm tetap punya jejak asal kandang Integration High A1 record 100 telur ke gudang farm dan A2 record 150 telur ke gudang farm yang sama. 1. Inspect row telur yang tersimpan. 2. Inspect hasil costing atau report setelahnya. Stok fisik pooled di gudang farm, tetapi asal kandang tetap bisa dibedakan per row atau allocation, HPP per kandang tetap dapat dihitung.
18 TC-D04 Recording Telur dan Atribusi Recording Detail Known gap pada detail recording dipahami Known Limitation Low Sudah menjalankan TC-D02. 1. Buka detail recording setelah transaksi telur ke gudang farm. Logika bisnis tetap berjalan, tetapi detail API atau UI mungkin belum menampilkan egg-origin secara eksplisit karena detail DTO belum diperluas.
19 TC-E01 Depletion dan Atribusi Populasi Recording Depletion dari gudang ayam milik kandang normal Regression High A1 memiliki populasi ayam di gudang kandang. 1. Buat depletion. 2. Approve. Depletion berhasil, alokasi populasi ter-resolve ke A1, HPP atau usage tetap benar.
20 TC-E02 Depletion dan Atribusi Populasi Recording Depletion dari sumber ayam fisik farm-level dengan source kandang A1 UAT High Stok ayam secara fisik ada di gudang farm dan punya jejak sumber ke A1. 1. Buat depletion untuk A1. 2. Gunakan path source atau farm-level yang didukung backend. 3. Approve. source_product_warehouse_id menunjuk ke sumber fisik yang benar, source_project_flock_kandang_id bernilai A1, alokasi populasi berhasil tanpa mengasumsikan gudang fisik milik A1.
21 TC-E03 Depletion dan Atribusi Populasi Recording Depletion gagal bila sumber populasi tidak dapat diatribusikan Negative High Buat kasus stok ayam farm-level tanpa source kandang yang valid. 1. Coba approve depletion. Backend menolak dengan error yang jelas dan tidak ada silent misattribution.
22 TC-F01 Marketing dan Penjualan Sales Order Sales order dari gudang kandang tetap berjalan Regression High Stok produk tersedia di Gudang Kandang A1. 1. Buat SO dari Gudang Kandang A1. 2. Lakukan delivery. Perilaku lama tetap berjalan normal.
23 TC-F02 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk telur UAT High Stok telur farm-level tersedia dan berasal dari A1. 1. Buat SO menggunakan Gudang Farm A. 2. Lakukan delivery. SO dan DO berhasil, stok fisik berkurang dari gudang farm, HPP dan COGS telur tetap teratribusi ke kandang penghasil melalui allocation.
24 TC-F03 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk telur pooled A1 dan A2 Integration High Stok telur pooled tersedia di gudang farm dari A1 dan A2. 1. Buat penjualan. 2. Lakukan delivery. 3. Inspect closing atau report. Stok fisik berkurang sekali dari gudang farm, revenue dan HPP terbagi benar ke A1 dan A2, tidak bergantung pada pw.project_flock_kandang_id.
25 TC-F04 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk ayam atau culling UAT High Stok ayam atau culling farm-level tersedia dengan jejak sumber dari A1 dan A2. 1. Buat SO dari gudang farm. 2. Buat DO dan approve. allocatePopulationForMarketingDelivery menurunkan atribusi kandang dari source groups atau allocation, tidak gagal karena gudang jual tidak punya project_flock_kandang_id, HPP dan COGS teratribusi ke kandang sumber.
26 TC-F05 Marketing dan Penjualan Frontend Marketing UI sales menampilkan semantik Gudang Fisik UI Regression Medium Tidak ada setup khusus selain akses ke form SO. 1. Buka form SO. 2. Periksa label selector gudang dan label tabel produk. UI menggunakan label Gudang Fisik, bukan Kandang yang menyesatkan, dan label produk memuat detail produk serta gudang atau scope.
27 TC-F06 Marketing dan Penjualan Delivery Order Layar delivery order tetap kompatibel Regression Medium Sudah ada SO dari gudang farm. 1. Lakukan delivery untuk SO farm-level. 2. Periksa tabel dan detail DO. Tidak ada masalah payload, gudang fisik tampil dengan benar, dan tidak ada kebingungan akibat wording lama berbasis kandang.
28 TC-G01 Report, Closing, dan HPP Daily Marketing Report Daily marketing report untuk penjualan telur farm-level UAT Medium Sudah menjalankan TC-F02. 1. Jalankan daily marketing report. 2. Uji export. Row muncul pada gudang fisik yang benar, report tidak menyiratkan gudang sama dengan kandang, export berjalan.
29 TC-G02 Report, Closing, dan HPP Closing Sales Closing sales untuk penjualan pooled farm-level UAT High Ada penjualan pooled telur atau ayam dari gudang farm. 1. Buka closing sales. Penjualan bisa tampil teratribusi per kandang, label menunjukkan Kandang Atribusi, HPP dan revenue tetap benar secara matematis.
30 TC-G03 Report, Closing, dan HPP HPP per Kandang HPP per kandang mencakup konsumsi pakan atau OVK dari gudang farm UAT High A1 sudah memakai pakan atau OVK dari gudang farm. 1. Jalankan report HPP per kandang. Biaya usage muncul di A1 dan tidak hilang walaupun gudang fisiknya level farm.
31 TC-G04 Report, Closing, dan HPP Closing Sapronak Outgoing sapronak menampilkan gudang fisik dengan benar UI Regression Medium Ada data outgoing sapronak yang valid. 1. Buka tabel closing outgoing sapronak. Header jelas menunjukkan Gudang Asal (Fisik) dan Gudang Tujuan (Fisik).
32 TC-G05 Report, Closing, dan HPP Compatibility Data historis kandang-owned dan pooled data baru dapat coexist Regression High Dalam satu date range ada transaksi lama kandang-owned dan transaksi baru pooled farm-level. 1. Jalankan closing. 2. Jalankan report. 3. Jalankan HPP. Kedua jenis data diproses dengan benar, tidak ada double count dan tidak ada atribusi yang hilang.
33 TC-H01 FIFO-v2 dan Integritas Allocation FIFO-v2 Kontrak FIFO-v2 tidak berubah Integration High Gunakan data uji yang mencakup recording stock, depletion, egg, dan marketing. 1. Verifikasi route FIFO untuk RECORDING_STOCK_OUT, RECORDING_DEPLETION_OUT, RECORDING_DEPLETION_IN, RECORDING_EGG_IN, dan MARKETING_OUT. 2. Bandingkan dengan RFC.md dan seed config FIFO-v2. Tidak ada perubahan semantik route yang tidak disengaja.
34 TC-H02 FIFO-v2 dan Integritas Allocation Stock Allocation Stock allocation tetap konsisten untuk pakan dari gudang farm Integration High Sudah menjalankan TC-C02. 1. Inspect stock_allocations setelah transaksi. Allocation consume terbentuk dengan benar dan tidak ada row allocation yatim atau rusak.
35 TC-H03 FIFO-v2 dan Integritas Allocation Stock Allocation Stock allocation tetap konsisten untuk penjualan telur pooled Integration High Sudah menjalankan TC-F03. 1. Inspect stock_allocations. 2. Inspect row atribusi turunannya. Allocation mendukung atribusi HPP kembali ke kandang sumber.
36 TC-H04 FIFO-v2 dan Integritas Allocation Population Allocation Population allocation tetap konsisten untuk penjualan ayam pooled Integration High Sudah menjalankan TC-F04. 1. Inspect population allocations. Penggunaan kandang sumber teralokasi dengan benar dan tidak fallback ke atribusi null saat source tersedia.
37 TC-I01 Negative dan Guard Cases Recording Recording dari stok farm-level dengan qty tidak cukup Negative High Stok farm-level tersedia tetapi qty lebih kecil dari pemakaian yang diinput. 1. Buat recording dengan qty melebihi stok. 2. Submit atau approve. Muncul validation atau business error dan tidak ada korupsi parsial.
38 TC-I02 Negative dan Guard Cases Marketing Marketing dari stok farm-level dengan qty tidak cukup Negative High Stok farm-level tersedia tetapi qty lebih kecil dari qty penjualan. 1. Buat SO atau DO dengan qty melebihi stok. 2. Submit atau approve. Delivery atau approval diblok dan stok tetap konsisten.
39 TC-I03 Negative dan Guard Cases Frontend Selector Opsi produk sama di gudang berbeda tidak salah terpilih UI Regression Medium Produk yang sama tersedia di gudang farm dan gudang kandang. 1. Pilih masing-masing opsi secara eksplisit di UI. 2. Save. 3. Buka kembali edit atau detail. Opsi yang terpilih jelas dan tetap stabil setelah save atau edit.
40 TC-I04 Negative dan Guard Cases Product Warehouse Row gudang shared tidak diatribusikan ulang oleh flow maintenance Regression High Ada row shared farm warehouse yang sudah aktif. 1. Jalankan flow yang menyentuh logic ensure/find product warehouse. 2. Cek ulang row farm shared. Tidak ada mutasi diam-diam pada project_flock_kandang_id.
41 TC-J01 Regression Frontend dan UX Recording Form Form recording menampilkan opsi stok farm dan kandang hanya dalam scope farm yang sama UI Regression Medium Ada stok di gudang farm, gudang kandang saat ini, dan gudang kandang lain. 1. Buka form recording untuk kandang tertentu. 2. Periksa opsi stock selector. Gudang farm dan gudang kandang saat ini terlihat, gudang kandang lain tersembunyi.
42 TC-J02 Regression Frontend dan UX Recording Form Selector recording telur mengizinkan gudang farm UI Regression Medium Egg warehouse tersedia di gudang farm. 1. Buka form recording telur. 2. Buka selector tujuan telur. Gudang farm terlihat sebagai opsi tujuan telur.
43 TC-J03 Regression Frontend dan UX Sales Form Form sales memakai semantik gudang secara konsisten UI Regression Medium Akses ke halaman marketing tersedia. 1. Buka form sales. 2. Periksa label selector dan summary table. Label menggunakan Gudang Fisik secara konsisten dan tidak ada wording Kandang yang menyesatkan untuk stok fisik.
44 TC-J04 Regression Frontend dan UX Marketing Modal Modal list marketing menampilkan label gudang fisik UI Regression Low Akses ke modal product list tersedia. 1. Buka modal product list di marketing. Kolom menampilkan label Gudang Fisik.
45 TC-K01 Known Limitation Recording Detail Detail recording belum menampilkan source atau origin attribution baru Known Limitation Low Sudah ada recording telur farm-level dan depletion dengan source attribution. 1. Buat transaksi. 2. Buka detail recording. Transaksi berjalan dan atribusi tersimpan di DB, tetapi detail API atau UI mungkin belum menampilkan field source atau origin tersebut
Binary file not shown.
@@ -0,0 +1,343 @@
-- Legacy Egg Cutover Audit Helper Queries
-- Ad-hoc query pack for investigation, audit, dry-run review, and rollback readiness.
-- =====================================================================
-- AUDIT-01 All locations classified by kandang/farm egg posting timing
-- =====================================================================
WITH timing AS (
SELECT
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pk.project_flock_id
JOIN locations l ON l.id = pf.location_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
GROUP BY pf.location_id, l.name
)
SELECT
location_id,
location_name,
first_kandang_date,
last_kandang_date,
first_farm_date,
last_farm_date,
CASE
WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY'
WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER'
ELSE 'OVERLAP'
END AS location_status
FROM timing
ORDER BY location_name;
-- =====================================================================
-- AUDIT-02 All legacy kandang egg product warehouses with positive on-hand
-- =====================================================================
WITH first_farm AS (
SELECT location_id, MIN(id) AS farm_warehouse_id
FROM warehouses
WHERE type = 'LOKASI'
AND deleted_at IS NULL
GROUP BY location_id
)
SELECT
l.id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
p.id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
LEFT JOIN first_farm ff ON ff.location_id = kw.location_id
LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id
WHERE EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
ORDER BY l.name, kw.name, p.name;
-- =====================================================================
-- AUDIT-03 Totals per location for phase sizing
-- =====================================================================
WITH candidates AS (
SELECT
l.name AS location_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
)
SELECT
location_name,
COUNT(*) AS positive_rows,
SUM(on_hand_qty) AS total_on_hand_qty
FROM candidates
GROUP BY location_name
ORDER BY location_name;
-- =====================================================================
-- AUDIT-04 Locations missing farm warehouse
-- =====================================================================
SELECT
l.id AS location_id,
l.name AS location_name
FROM locations l
WHERE EXISTS (
SELECT 1
FROM warehouses kw
WHERE kw.location_id = l.id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1
FROM warehouses fw
WHERE fw.location_id = l.id
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
)
ORDER BY l.name;
-- =====================================================================
-- AUDIT-05 Legacy recording_eggs still pointing to kandang warehouse
-- =====================================================================
SELECT
l.name AS location_name,
kw.name AS kandang_warehouse_name,
p.name AS product_name,
COUNT(*) AS recording_rows
FROM recording_eggs re
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE kw.type = 'KANDANG'
GROUP BY l.name, kw.name, p.name
ORDER BY l.name, kw.name, p.name;
-- =====================================================================
-- AUDIT-06 Farm-level recording_eggs already present
-- =====================================================================
SELECT
l.name AS location_name,
fw.name AS farm_warehouse_name,
p.name AS product_name,
COUNT(*) AS recording_rows
FROM recording_eggs re
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE fw.type = 'LOKASI'
GROUP BY l.name, fw.name, p.name
ORDER BY l.name, fw.name, p.name;
-- =====================================================================
-- AUDIT-07 Transfers created by cutover reason, grouped by run_id
-- =====================================================================
SELECT
SPLIT_PART(SPLIT_PART(st.reason, '|run_id=', 2), '|', 1) AS run_id,
COUNT(DISTINCT st.id) AS transfer_count,
COUNT(std.id) AS detail_count,
SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty,
MIN(st.transfer_date) AS first_transfer_date,
MAX(st.transfer_date) AS last_transfer_date
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=%'
GROUP BY 1
ORDER BY first_transfer_date DESC, run_id DESC;
-- =====================================================================
-- AUDIT-08 Detailed summary per run_id
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
st.transfer_date,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
st.deleted_at
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name;
-- =====================================================================
-- AUDIT-09 Downstream consumption check per run_id
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sa.usable_type,
sa.usable_id,
sa.qty,
sa.function_code,
sa.flag_group_code
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_allocations sa
ON sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.deleted_at IS NULL
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sa.usable_type, sa.usable_id;
-- =====================================================================
-- AUDIT-10 Stock log reconciliation per cutover transfer detail
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
std.id AS transfer_detail_id,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END) AS total_logged_out,
SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END) AS total_logged_in
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
LEFT JOIN stock_logs sl
ON sl.loggable_type = 'TRANSFER'
AND sl.loggable_id = std.id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
GROUP BY st.id, st.movement_number, p.name, std.id, COALESCE(std.total_qty, std.usage_qty, 0)
ORDER BY st.id, p.name;
-- =====================================================================
-- AUDIT-11 New recording eggs still posting to kandang after cutoff date
-- Replace values before running.
-- =====================================================================
SELECT
DATE(r.record_datetime) AS record_date,
l.name AS location_name,
kw.name AS kandang_warehouse_name,
p.name AS product_name,
re.qty
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE kw.type = 'KANDANG'
AND LOWER(l.name) = LOWER('<location_name>')
AND DATE(r.record_datetime) >= DATE('<cutover_date>')
ORDER BY r.record_datetime ASC, kw.name, p.name;
-- Expectation:
-- - after deploy and cutover, this should ideally return 0 rows for the location
-- =====================================================================
-- AUDIT-12 Combined kandang + farm egg stock per location after cutover
-- Replace <location_name> before running.
-- =====================================================================
SELECT
l.name AS location_name,
w.type AS warehouse_type,
p.name AS product_name,
SUM(COALESCE(pw.qty, 0)) AS total_qty
FROM product_warehouses pw
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN locations l ON l.id = w.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
GROUP BY l.name, w.type, p.name
ORDER BY w.type, p.name;
@@ -0,0 +1,400 @@
-- Legacy Egg Cutover Verification Checklist
-- Usage:
-- 1. Replace the values below before executing.
-- 2. Run section BEFORE before --apply.
-- 3. Run section AFTER after --apply.
-- 4. Run rollback checks if needed.
-- =====================================================================
-- PARAMETERS
-- =====================================================================
-- Replace manually before running.
-- Example:
-- location_name = Jamali
-- cutover_date = 2026-04-07
-- run_id = egg-cutover-20260407T130344.220407000Z
-- =====================================================================
-- BEFORE APPLY
-- =====================================================================
-- [BEFORE-01] Identify target location and farm warehouse
SELECT
l.id AS location_id,
l.name AS location_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name
FROM locations l
LEFT JOIN warehouses fw
ON fw.location_id = l.id
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
WHERE LOWER(l.name) = LOWER('<location_name>')
ORDER BY fw.id ASC;
-- Expectation:
-- - exactly one target location
-- - at least one farm warehouse exists
-- [BEFORE-02] Verify location timing status (must be CLEAN_CUTOVER for phase 1)
WITH timing AS (
SELECT
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pk.project_flock_id
JOIN locations l ON l.id = pf.location_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE LOWER(l.name) = LOWER('<location_name>')
GROUP BY pf.location_id, l.name
)
SELECT
location_id,
location_name,
first_kandang_date,
last_kandang_date,
first_farm_date,
last_farm_date,
CASE
WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY'
WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER'
ELSE 'OVERLAP'
END AS location_status
FROM timing;
-- Expectation:
-- - phase 1 location must be CLEAN_CUTOVER
-- [BEFORE-03] Candidate source rows that should be migrated
WITH first_farm AS (
SELECT location_id, MIN(id) AS farm_warehouse_id
FROM warehouses
WHERE type = 'LOKASI'
AND deleted_at IS NULL
GROUP BY location_id
)
SELECT
l.id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
p.id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
LEFT JOIN first_farm ff ON ff.location_id = kw.location_id
LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND EXISTS (
SELECT 1
FROM recording_eggs re
WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
ORDER BY kw.name, p.name;
-- Expectation:
-- - every row here should match dry-run eligible rows
-- [BEFORE-04] Totals per source warehouse and product
WITH candidates AS (
SELECT
kw.name AS source_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
)
SELECT
source_warehouse_name,
product_name,
SUM(on_hand_qty) AS total_qty
FROM candidates
GROUP BY source_warehouse_name, product_name
ORDER BY source_warehouse_name, product_name;
-- [BEFORE-05] Current farm egg stock before cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS farm_on_hand_qty
FROM warehouses fw
JOIN locations l ON l.id = fw.location_id
JOIN product_warehouses pw ON pw.warehouse_id = fw.id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
ORDER BY p.name;
-- [BEFORE-06] Existing cutover transfers for this location
SELECT
st.id,
st.movement_number,
st.transfer_date,
st.reason,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
st.deleted_at
FROM stock_transfers st
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
LEFT JOIN locations l ON l.id = COALESCE(ws.location_id, wd.location_id)
WHERE LOWER(COALESCE(l.name, '')) = LOWER('<location_name>')
AND st.reason LIKE 'EGG_FARM_CUTOVER|%'
ORDER BY st.id DESC;
-- Expectation:
-- - no unexpected older active cutover transfers for the same location
-- =====================================================================
-- AFTER APPLY
-- =====================================================================
-- [AFTER-01] Transfer headers created by run_id
SELECT
st.id,
st.movement_number,
st.transfer_date,
st.reason,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
st.deleted_at
FROM stock_transfers st
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id ASC;
-- [AFTER-02] Transfer detail rows created by run_id
SELECT
st.id AS transfer_id,
st.movement_number,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
std.source_product_warehouse_id,
std.dest_product_warehouse_id
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name;
-- [AFTER-03] Stock logs created by run_id transfer details
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sl.product_warehouse_id,
sl.increase,
sl.decrease,
sl.stock,
sl.created_at
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_logs sl
ON sl.loggable_type = 'TRANSFER'
AND sl.loggable_id = std.id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sl.id;
-- Expectation:
-- - every detail has one stock log decrease from source and one stock log increase to destination
-- [AFTER-04] Source rows after cutover
SELECT
kw.name AS source_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS source_qty_after
FROM product_warehouses pw
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND kw.type = 'KANDANG'
AND EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
ORDER BY kw.name, p.name;
-- Expectation:
-- - rows that were transferred should now be 0 or no longer available for use
-- [AFTER-05] Farm rows after cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS farm_qty_after
FROM product_warehouses pw
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
ORDER BY fw.name, p.name;
-- Expectation:
-- - farm qty increases by the moved amount
-- [AFTER-06] Reconciliation: total moved by run
SELECT
p.name AS product_name,
SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
GROUP BY p.name
ORDER BY p.name;
-- [AFTER-07] Farm stock available for SO after cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS available_qty
FROM product_warehouses pw
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
AND COALESCE(pw.qty, 0) > 0
ORDER BY p.name;
-- =====================================================================
-- ROLLBACK CHECKS
-- =====================================================================
-- [ROLLBACK-01] Check downstream consumption guard before rollback
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sa.usable_type,
sa.usable_id,
sa.qty,
sa.function_code,
sa.flag_group_code
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_allocations sa
ON sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.deleted_at IS NULL
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sa.usable_type, sa.usable_id;
-- Expectation:
-- - rollback only safe if this query returns 0 rows
-- [ROLLBACK-02] Verify run is fully rolled back
SELECT
st.id,
st.movement_number,
st.deleted_at
FROM stock_transfers st
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id;
-- Expectation:
-- - after rollback, deleted_at should be filled for all transfers in the run
@@ -0,0 +1,224 @@
package repository
import (
"fmt"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type MarketingDeliveryAttributionRow struct {
MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"`
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProjectFlockID uint `gorm:"column:project_flock_id"`
ProjectFlockCategory string `gorm:"column:project_flock_category"`
AllocatedQty float64 `gorm:"column:allocated_qty"`
}
func MarketingDeliveryAttributionRowsQuery(db *gorm.DB) *gorm.DB {
sql := `
WITH mapped AS (
SELECT
sa.usable_id AS marketing_delivery_product_id,
pc.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN project_flock_populations pfp
ON pfp.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, pc.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN recording_eggs re
ON re.id = sa.stockable_id
AND sa.stockable_type = ?
LEFT JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN recording_depletions rd
ON rd.id = sa.stockable_id
AND sa.stockable_type = ?
LEFT JOIN recordings r ON r.id = rd.recording_id
JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
pi.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN purchase_items pi
ON pi.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_flock_kandangs pfk ON pfk.id = pi.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND pi.project_flock_kandang_id IS NOT NULL
GROUP BY sa.usable_id, pi.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
source_pw.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN stock_transfer_details std
ON std.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id
JOIN project_flock_kandangs pfk ON pfk.id = source_pw.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND source_pw.project_flock_kandang_id IS NOT NULL
GROUP BY sa.usable_id, source_pw.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
ltt.target_project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN laying_transfer_targets ltt
ON ltt.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_flock_kandangs pfk ON pfk.id = ltt.target_project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, ltt.target_project_flock_kandang_id, pfk.project_flock_id, pf.category
)
SELECT
src.marketing_delivery_product_id,
src.project_flock_kandang_id,
src.project_flock_id,
src.project_flock_category,
SUM(src.allocated_qty) AS allocated_qty
FROM (
SELECT
mapped.marketing_delivery_product_id,
mapped.project_flock_kandang_id,
mapped.project_flock_id,
mapped.project_flock_category,
mapped.allocated_qty
FROM mapped
UNION ALL
SELECT
mdp.id AS marketing_delivery_product_id,
pw.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
COALESCE(mdp.usage_qty, 0) AS allocated_qty
FROM marketing_delivery_products mdp
JOIN marketing_products mp ON mp.id = mdp.marketing_product_id
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
LEFT JOIN mapped ON mapped.marketing_delivery_product_id = mdp.id
WHERE mapped.marketing_delivery_product_id IS NULL
AND pw.project_flock_kandang_id IS NOT NULL
AND COALESCE(mdp.usage_qty, 0) > 0
) src
GROUP BY
src.marketing_delivery_product_id,
src.project_flock_kandang_id,
src.project_flock_id,
src.project_flock_category
`
return db.Raw(
sql,
fifo.StockableKeyProjectFlockPopulation.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyRecordingDepletion.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyStockTransferIn.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyTransferToLayingIn.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
)
}
func MarketingDeliverySingleAttributionQuery(db *gorm.DB) *gorm.DB {
return db.
Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)).
Select(`
mda.marketing_delivery_product_id,
CASE
WHEN COUNT(DISTINCT mda.project_flock_kandang_id) = 1 THEN MIN(mda.project_flock_kandang_id)
ELSE NULL
END AS attributed_project_flock_kandang_id
`).
Group("mda.marketing_delivery_product_id")
}
func MarketingDeliveryAttributionFilterSQL(column string) string {
return fmt.Sprintf("EXISTS (SELECT 1 FROM (?) AS mda WHERE mda.marketing_delivery_product_id = %s)", column)
}
@@ -0,0 +1,147 @@
package repository
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestMarketingDeliveryAttributionRowsQueryIncludesMappedAndFallbackRows(t *testing.T) {
db := setupMarketingAttributionTestDB(t)
statements := []string{
`INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`,
`INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`,
`INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`,
`INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`,
`INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`,
`INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`,
`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES
(1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'),
(2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'),
(3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding fixtures: %v", err)
}
}
var rows []MarketingDeliveryAttributionRow
if err := db.Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)).
Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC").
Scan(&rows).Error; err != nil {
t.Fatalf("failed scanning attribution rows: %v", err)
}
if len(rows) != 4 {
t.Fatalf("expected 4 attribution rows, got %d", len(rows))
}
if rows[0].MarketingDeliveryProductID != 601 || rows[0].ProjectFlockKandangID != 101 || rows[0].AllocatedQty != 60 {
t.Fatalf("unexpected first attribution row: %+v", rows[0])
}
if rows[1].MarketingDeliveryProductID != 601 || rows[1].ProjectFlockKandangID != 102 || rows[1].AllocatedQty != 40 {
t.Fatalf("unexpected second attribution row: %+v", rows[1])
}
if rows[2].MarketingDeliveryProductID != 602 || rows[2].ProjectFlockKandangID != 101 || rows[2].AllocatedQty != 25 {
t.Fatalf("unexpected fallback attribution row: %+v", rows[2])
}
if rows[3].MarketingDeliveryProductID != 603 || rows[3].ProjectFlockKandangID != 101 || rows[3].AllocatedQty != 12 {
t.Fatalf("unexpected egg attribution row: %+v", rows[3])
}
}
func TestMarketingDeliverySingleAttributionQueryOnlyReturnsSingleSourceRows(t *testing.T) {
db := setupMarketingAttributionTestDB(t)
statements := []string{
`INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`,
`INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`,
`INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`,
`INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`,
`INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`,
`INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`,
`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES
(1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'),
(2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'),
(3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding fixtures: %v", err)
}
}
type singleRow struct {
MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"`
AttributedProjectFlockKandangID *uint `gorm:"column:attributed_project_flock_kandang_id"`
}
var rows []singleRow
if err := db.Table("(?) AS mda", MarketingDeliverySingleAttributionQuery(db)).
Order("mda.marketing_delivery_product_id ASC").
Scan(&rows).Error; err != nil {
t.Fatalf("failed scanning single attribution rows: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
if rows[0].MarketingDeliveryProductID != 601 || rows[0].AttributedProjectFlockKandangID != nil {
t.Fatalf("expected pooled delivery 601 to have nil single attribution, got %+v", rows[0])
}
if rows[1].MarketingDeliveryProductID != 602 || rows[1].AttributedProjectFlockKandangID == nil || *rows[1].AttributedProjectFlockKandangID != 101 {
t.Fatalf("expected fallback delivery 602 to map to kandang 101, got %+v", rows[1])
}
if rows[2].MarketingDeliveryProductID != 603 || rows[2].AttributedProjectFlockKandangID == nil || *rows[2].AttributedProjectFlockKandangID != 101 {
t.Fatalf("expected egg delivery 603 to map to kandang 101, got %+v", rows[2])
}
}
func setupMarketingAttributionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER,
stockable_type TEXT,
stockable_id INTEGER,
usable_type TEXT,
usable_id INTEGER,
qty NUMERIC(15,3),
status TEXT,
allocation_purpose TEXT
)`,
`CREATE TABLE project_flock_populations (id INTEGER PRIMARY KEY, project_chickin_id INTEGER)`,
`CREATE TABLE project_chickins (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER)`,
`CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER)`,
`CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, category TEXT)`,
`CREATE TABLE marketing_delivery_products (id INTEGER PRIMARY KEY, marketing_product_id INTEGER, usage_qty NUMERIC(15,3))`,
`CREATE TABLE marketing_products (id INTEGER PRIMARY KEY, product_warehouse_id INTEGER)`,
`CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE recording_eggs (id INTEGER PRIMARY KEY, recording_id INTEGER, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE recordings (id INTEGER PRIMARY KEY, project_flock_kandangs_id INTEGER NULL)`,
`CREATE TABLE recording_depletions (id INTEGER PRIMARY KEY, recording_id INTEGER, source_project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE purchase_items (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE stock_transfer_details (id INTEGER PRIMARY KEY, source_product_warehouse_id INTEGER NULL)`,
`CREATE TABLE laying_transfer_targets (id INTEGER PRIMARY KEY, target_project_flock_kandang_id INTEGER NULL)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -0,0 +1,104 @@
package service
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
)
type FifoPendingPolicyInput struct {
Lane string
FlagGroupCode string
FunctionCode string
LegacyTypeKey string
}
type FifoPendingPolicyResult struct {
AllowPending bool
RuleSource string
Found bool
}
func ResolveFifoPendingPolicy(ctx context.Context, tx *gorm.DB, input FifoPendingPolicyInput) (*FifoPendingPolicyResult, error) {
if tx == nil {
return nil, gorm.ErrInvalidDB
}
lane := strings.ToUpper(strings.TrimSpace(input.Lane))
flagGroupCode := strings.ToUpper(strings.TrimSpace(input.FlagGroupCode))
functionCode := strings.ToUpper(strings.TrimSpace(input.FunctionCode))
legacyTypeKey := strings.ToUpper(strings.TrimSpace(input.LegacyTypeKey))
if lane == "" {
return &FifoPendingPolicyResult{
AllowPending: false,
RuleSource: "SAFE_DEFAULT_BLOCK",
Found: false,
}, nil
}
type overconsumeRuleRow struct {
Allow bool `gorm:"column:allow_overconsume"`
}
var overconsume overconsumeRuleRow
overconsumeErr := tx.WithContext(ctx).
Table("fifo_stock_v2_overconsume_rules").
Select("allow_overconsume").
Where("is_active = TRUE").
Where("lane = ?", lane).
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
Where("(function_code IS NULL OR function_code = ?)", functionCode).
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
Order("priority ASC, id ASC").
Limit(1).
Take(&overconsume).Error
if overconsumeErr == nil {
return &FifoPendingPolicyResult{
AllowPending: overconsume.Allow,
RuleSource: "OVERCONSUME_RULE",
Found: true,
}, nil
}
if !errors.Is(overconsumeErr, gorm.ErrRecordNotFound) {
return nil, overconsumeErr
}
type routeRuleRow struct {
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
}
var routeRule routeRuleRow
routeQuery := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Select("allow_pending_default").
Where("is_active = TRUE").
Where("lane = ?", lane).
Where("flag_group_code = ?", flagGroupCode)
if legacyTypeKey != "" {
routeQuery = routeQuery.Where("legacy_type_key = ?", legacyTypeKey)
}
if functionCode != "" {
routeQuery = routeQuery.Where("function_code = ?", functionCode)
}
routeErr := routeQuery.
Order("id ASC").
Limit(1).
Take(&routeRule).Error
if routeErr == nil {
return &FifoPendingPolicyResult{
AllowPending: routeRule.AllowPendingDefault,
RuleSource: "ROUTE_RULE_DEFAULT",
Found: true,
}, nil
}
if !errors.Is(routeErr, gorm.ErrRecordNotFound) {
return nil, routeErr
}
return &FifoPendingPolicyResult{
AllowPending: false,
RuleSource: "SAFE_DEFAULT_BLOCK",
Found: false,
}, nil
}
@@ -141,6 +141,9 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
if remaining <= 0 {
break
}
if shouldSkipStockableForUsable(req, lot.Ref.LegacyTypeKey) {
continue
}
if lot.AvailableQuantity <= 0 {
continue
}
@@ -207,6 +210,23 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
return result, nil
}
func shouldSkipStockableForUsable(req AllocateRequest, stockableType string) bool {
usableType := strings.ToUpper(strings.TrimSpace(req.Usable.LegacyTypeKey))
functionCode := strings.ToUpper(strings.TrimSpace(req.Usable.FunctionCode))
stockable := strings.ToUpper(strings.TrimSpace(stockableType))
// CHICKIN_OUT must consume physical stock sources, not population lots,
// otherwise approved chickin can consume its own just-created population.
if (usableType == "PROJECT_CHICKIN" || functionCode == "CHICKIN_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
return true
}
if (usableType == "STOCK_TRANSFER_OUT" || functionCode == "STOCK_TRANSFER_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
return true
}
return false
}
func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) {
if err := s.validateRollbackRequest(req); err != nil {
return nil, err
@@ -479,10 +499,6 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
if len(rollbackRes.Details) > 0 {
result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...)
}
minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity
if desiredQty < minDesired {
desiredQty = minDesired
}
if desiredQty <= 0 {
continue
@@ -685,16 +701,17 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var latest row
err := tx.WithContext(ctx).
latestQuery := tx.WithContext(ctx).
Table("stock_allocations").
Select("flag_group_code").
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
Where("engine_version = 'v2'").
Where("allocation_purpose = ?", defaultAllocationPurpose()).
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''").
Order("id DESC").
Limit(1).
Take(&latest).Error
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''")
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
latestQuery = latestQuery.Where("function_code = ?", code)
}
err := latestQuery.Order("id DESC").Limit(1).Take(&latest).Error
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
return latest.FlagGroupCode, nil
}
@@ -702,19 +719,56 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
return "", err
}
var rules []routeRule
err = tx.WithContext(ctx).
rulesQuery := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("lane = ?", string(LaneUsable)).
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey).
Find(&rules).Error
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey)
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
rulesQuery = rulesQuery.Where("function_code = ?", code)
}
var rules []routeRule
err = rulesQuery.Find(&rules).Error
if err != nil {
return "", err
}
if len(rules) == 0 {
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey)
}
if len(rules) > 1 && req.ProductWarehouseID != 0 {
type candidateRow struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var candidates []candidateRow
byProductQuery := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("DISTINCT rr.flag_group_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.lane = ?", string(LaneUsable)).
Where("rr.legacy_type_key = ?", req.Usable.LegacyTypeKey).
Where(`
EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = ?
AND f.flagable_type = 'products'
AND fm.flag_group_code = rr.flag_group_code
)
`, req.ProductWarehouseID)
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
byProductQuery = byProductQuery.Where("rr.function_code = ?", code)
}
if err := byProductQuery.Order("rr.flag_group_code ASC").Scan(&candidates).Error; err != nil {
return "", err
}
if len(candidates) == 1 {
return strings.TrimSpace(candidates[0].FlagGroupCode), nil
}
}
if len(rules) > 1 {
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
}
+7 -1
View File
@@ -57,6 +57,7 @@ var (
SSOPortalURL string
SSOClients map[string]SSOClientConfig
SSOAccessCookieName string
SSOAccessCookieFallback []string
SSORefreshCookieName string
SSOCookieDomain string
SSOCookieSecure bool
@@ -107,7 +108,7 @@ func init() {
JWTResetPasswordExp = viper.GetInt("JWT_RESET_PASSWORD_EXP_MINUTES")
JWTVerifyEmailExp = viper.GetInt("JWT_VERIFY_EMAIL_EXP_MINUTES")
//Cors
// Cors
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With")
@@ -147,6 +148,7 @@ func init() {
SSOGetMeURL = viper.GetString("SSO_GETME_URL")
SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL"))
SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access")
SSOAccessCookieFallback = parseList("SSO_ACCESS_COOKIE_FALLBACK")
SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh")
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE")
@@ -259,6 +261,10 @@ func defaultString(v, def string) string {
return v
}
func LayingWeekStart() int {
return TransferToLayingGrowingMaxWeek
}
func joinPath(parts ...string) string {
out := make([]string, 0, len(parts))
for _, part := range parts {
+1 -1
View File
@@ -37,7 +37,7 @@ func Connect(dbHost, dbName string) *gorm.DB {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
SkipDefaultTransaction: true,
PrepareStmt: true,
PrepareStmt: false,
TranslateError: true,
})
if err != nil {
@@ -0,0 +1,57 @@
CREATE TABLE IF NOT EXISTS project_chickins (
id BIGSERIAL PRIMARY KEY,
project_flock_kandang_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
chick_in_date DATE NOT NULL,
usage_qty NUMERIC(15, 3) NOT NULL,
pending_usage_qty NUMERIC(15, 3) DEFAULT 0,
notes TEXT,
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
DO $$
BEGIN
IF to_regclass('project_flock_kandangs') IS NOT NULL THEN
ALTER TABLE project_chickins
ADD CONSTRAINT fk_project_chickins_kandang
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
IF to_regclass('product_warehouses') IS NOT NULL THEN
ALTER TABLE project_chickins
ADD CONSTRAINT fk_project_chickins_warehouse
FOREIGN KEY (product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF to_regclass('users') IS NOT NULL THEN
ALTER TABLE project_chickins
ADD CONSTRAINT fk_project_chickins_created_by
FOREIGN KEY (created_by)
REFERENCES users(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_chickins_kandang_id ON project_chickins (project_flock_kandang_id)
WHERE
deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_chickins_warehouse_id ON project_chickins (product_warehouse_id)
WHERE
deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_chickins_created_by ON project_chickins (created_by);
CREATE INDEX IF NOT EXISTS idx_chickins_kandang_deleted ON project_chickins (
project_flock_kandang_id,
deleted_at
);
CREATE INDEX IF NOT EXISTS idx_chickins_deleted_at ON project_chickins (deleted_at);
@@ -0,0 +1,60 @@
CREATE TABLE IF NOT EXISTS project_flock_populations (
id BIGSERIAL PRIMARY KEY,
project_chickin_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
total_qty NUMERIC(15, 3) NOT NULL,
total_used_qty NUMERIC(15, 3) DEFAULT 0,
notes TEXT,
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
DO $$
BEGIN
IF to_regclass('project_chickins') IS NOT NULL THEN
ALTER TABLE project_flock_populations
ADD CONSTRAINT fk_project_flock_populations_chickin
FOREIGN KEY (project_chickin_id)
REFERENCES project_chickins(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
IF to_regclass('product_warehouses') IS NOT NULL THEN
ALTER TABLE project_flock_populations
ADD CONSTRAINT fk_project_flock_populations_warehouse
FOREIGN KEY (product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
IF to_regclass('users') IS NOT NULL THEN
ALTER TABLE project_flock_populations
ADD CONSTRAINT fk_project_flock_populations_created_by
FOREIGN KEY (created_by)
REFERENCES users(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_populations_chickin_id ON project_flock_populations (project_chickin_id)
WHERE
deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_populations_warehouse_id ON project_flock_populations (product_warehouse_id)
WHERE
deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_populations_created_by ON project_flock_populations (created_by);
CREATE INDEX IF NOT EXISTS idx_populations_chickin_deleted ON project_flock_populations (
project_chickin_id,
deleted_at
);
CREATE INDEX IF NOT EXISTS idx_populations_deleted_at ON project_flock_populations (deleted_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_populations_chickin_unique ON project_flock_populations (project_chickin_id)
WHERE
deleted_at IS NULL;
@@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS project_chickin_details (
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_chickins') THEN
IF to_regclass('project_chickins') IS NOT NULL THEN
ALTER TABLE project_chickin_details
ADD CONSTRAINT fk_project_chickin_id
FOREIGN KEY (project_chickin_id)
@@ -20,7 +20,7 @@ BEGIN
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN
IF to_regclass('product_warehouses') IS NOT NULL THEN
ALTER TABLE project_chickin_details
ADD CONSTRAINT fk_product_warehouse_id
FOREIGN KEY (product_warehouse_id)
@@ -28,7 +28,7 @@ BEGIN
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
IF to_regclass('users') IS NOT NULL THEN
ALTER TABLE project_chickin_details
ADD CONSTRAINT fk_created_by
FOREIGN KEY (created_by)
@@ -42,4 +42,4 @@ CREATE INDEX IF NOT EXISTS idx_project_chickin_details_project_chickin_id ON pro
CREATE INDEX IF NOT EXISTS idx_project_chickin_details_product_warehouse_id ON project_chickin_details (product_warehouse_id);
CREATE INDEX IF NOT EXISTS idx_project_chickin_details_created_by ON project_chickin_details (created_by);
CREATE INDEX IF NOT EXISTS idx_project_chickin_details_created_by ON project_chickin_details (created_by);
@@ -0,0 +1,24 @@
BEGIN;
DROP INDEX IF EXISTS idx_stock_allocations_idempotency;
DROP INDEX IF EXISTS idx_stock_allocations_flag_group;
DROP INDEX IF EXISTS idx_stock_allocations_engine_version;
ALTER TABLE stock_allocations
DROP COLUMN IF EXISTS idempotency_key,
DROP COLUMN IF EXISTS reflow_run_id,
DROP COLUMN IF EXISTS function_code,
DROP COLUMN IF EXISTS flag_group_code,
DROP COLUMN IF EXISTS engine_version;
DROP TABLE IF EXISTS fifo_stock_v2_shadow_allocations;
DROP TABLE IF EXISTS fifo_stock_v2_reflow_checkpoints;
DROP TABLE IF EXISTS fifo_stock_v2_reflow_runs;
DROP TABLE IF EXISTS fifo_stock_v2_operation_log;
DROP TABLE IF EXISTS fifo_stock_v2_overconsume_rules;
DROP TABLE IF EXISTS fifo_stock_v2_route_rules;
DROP TABLE IF EXISTS fifo_stock_v2_traits;
DROP TABLE IF EXISTS fifo_stock_v2_flag_members;
DROP TABLE IF EXISTS fifo_stock_v2_flag_groups;
COMMIT;
@@ -0,0 +1,154 @@
BEGIN;
-- Bootstrap FIFO v2 core tables before seed migration (20260218090010).
-- Keep definitions aligned with 20260304033546_create_fifo_stock_v2_core.
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_groups (
code VARCHAR(64) PRIMARY KEY,
name VARCHAR(128) NOT NULL,
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_members (
flag_name VARCHAR(64) PRIMARY KEY,
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_traits (
id BIGSERIAL PRIMARY KEY,
source_table VARCHAR(64) NOT NULL,
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
date_table VARCHAR(64) NULL,
date_join_left_col VARCHAR(64) NULL,
date_join_right_col VARCHAR(64) NULL,
date_column VARCHAR(64) NOT NULL,
fallback_date_column VARCHAR(64) NULL,
sort_priority INT NOT NULL DEFAULT 100,
id_column VARCHAR(64) NOT NULL DEFAULT 'id',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (source_table, lane)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_route_rules (
id BIGSERIAL PRIMARY KEY,
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
function_code VARCHAR(64) NOT NULL,
source_table VARCHAR(64) NOT NULL,
source_id_column VARCHAR(64) NOT NULL DEFAULT 'id',
product_warehouse_col VARCHAR(64) NOT NULL,
quantity_col VARCHAR(64) NOT NULL,
used_quantity_col VARCHAR(64) NULL,
pending_quantity_col VARCHAR(64) NULL,
scope_sql TEXT NULL,
legacy_type_key VARCHAR(100) NOT NULL,
allow_pending_default BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (flag_group_code, lane, function_code, source_table)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_overconsume_rules (
id BIGSERIAL PRIMARY KEY,
flag_group_code VARCHAR(64) NULL REFERENCES fifo_stock_v2_flag_groups(code),
function_code VARCHAR(64) NULL,
lane VARCHAR(16) NOT NULL DEFAULT 'USABLE' CHECK (lane IN ('STOCKABLE', 'USABLE')),
allow_overconsume BOOLEAN NOT NULL,
priority INT NOT NULL DEFAULT 100,
reason TEXT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_operation_log (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(128) NOT NULL,
operation VARCHAR(16) NOT NULL CHECK (operation IN ('ALLOCATE', 'ROLLBACK', 'REFLOW', 'RECALCULATE')),
product_warehouse_id BIGINT NOT NULL,
flag_group_code VARCHAR(64) NOT NULL,
usable_type VARCHAR(100) NULL,
usable_id BIGINT NULL,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'DONE', 'FAILED')),
result_payload JSONB NULL,
error_text TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ NULL,
UNIQUE (idempotency_key, operation)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_runs (
id BIGSERIAL PRIMARY KEY,
mode VARCHAR(16) NOT NULL CHECK (mode IN ('DRY_RUN', 'APPLY')),
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'PAUSED', 'DONE', 'FAILED', 'CANCELLED')),
as_of TIMESTAMPTZ NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ NULL,
total_shards INT NOT NULL DEFAULT 0,
processed_shards INT NOT NULL DEFAULT 0,
processed_rows BIGINT NOT NULL DEFAULT 0,
mismatch_rows BIGINT NOT NULL DEFAULT 0,
created_by BIGINT NULL,
note TEXT NULL
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_checkpoints (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
flag_group_code VARCHAR(64) NOT NULL,
product_warehouse_id BIGINT NOT NULL,
last_sort_at TIMESTAMPTZ NULL,
last_source_table VARCHAR(64) NULL,
last_source_id BIGINT NULL,
status VARCHAR(16) NOT NULL CHECK (status IN ('PENDING', 'RUNNING', 'DONE', 'FAILED')) DEFAULT 'PENDING',
retry_count INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (run_id, flag_group_code, product_warehouse_id)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_shadow_allocations (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
product_warehouse_id BIGINT NOT NULL,
stockable_type VARCHAR(100) NOT NULL,
stockable_id BIGINT NOT NULL,
usable_type VARCHAR(100) NOT NULL,
usable_id BIGINT NOT NULL,
qty NUMERIC(15,3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
sort_at TIMESTAMPTZ NULL,
source_table VARCHAR(64) NULL,
source_id BIGINT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_usable
ON fifo_stock_v2_shadow_allocations(run_id, usable_type, usable_id);
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_stockable
ON fifo_stock_v2_shadow_allocations(run_id, stockable_type, stockable_id);
ALTER TABLE stock_allocations
ADD COLUMN IF NOT EXISTS engine_version VARCHAR(8) NOT NULL DEFAULT 'v1',
ADD COLUMN IF NOT EXISTS flag_group_code VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS function_code VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS reflow_run_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128) NULL;
CREATE INDEX IF NOT EXISTS idx_stock_allocations_engine_version
ON stock_allocations(engine_version);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_flag_group
ON stock_allocations(flag_group_code);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_idempotency
ON stock_allocations(idempotency_key);
COMMIT;
@@ -1,37 +1,60 @@
BEGIN;
DELETE FROM fifo_stock_v2_overconsume_rules
WHERE reason IN (
'fifo_v2_default_allow',
'fifo_v2_exception_ayam_depletion_block',
'fifo_v2_exception_marketing_block',
'fifo_v2_exception_transfer_block',
'fifo_v2_exception_adjustment_block',
'fifo_v2_exception_transfer_laying_block'
);
DO $$
BEGIN
IF to_regclass('public.fifo_stock_v2_overconsume_rules') IS NOT NULL THEN
EXECUTE '
DELETE FROM fifo_stock_v2_overconsume_rules
WHERE reason IN (
''fifo_v2_default_allow'',
''fifo_v2_exception_ayam_depletion_block'',
''fifo_v2_exception_marketing_block'',
''fifo_v2_exception_transfer_block'',
''fifo_v2_exception_adjustment_block'',
''fifo_v2_exception_transfer_laying_block''
)
';
END IF;
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
IF to_regclass('public.fifo_stock_v2_route_rules') IS NOT NULL THEN
EXECUTE '
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
DELETE FROM fifo_stock_v2_traits
WHERE source_table IN (
'purchase_items',
'stock_transfer_details',
'laying_transfer_targets',
'laying_transfer_sources',
'adjustment_stocks',
'recording_stocks',
'recording_depletions',
'recording_eggs',
'marketing_delivery_products',
'project_chickins',
'project_flock_populations'
);
IF to_regclass('public.fifo_stock_v2_traits') IS NOT NULL THEN
EXECUTE '
DELETE FROM fifo_stock_v2_traits
WHERE source_table IN (
''purchase_items'',
''stock_transfer_details'',
''laying_transfer_targets'',
''laying_transfer_sources'',
''adjustment_stocks'',
''recording_stocks'',
''recording_depletions'',
''recording_eggs'',
''marketing_delivery_products'',
''project_chickins'',
''project_flock_populations''
)
';
END IF;
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
IF to_regclass('public.fifo_stock_v2_flag_members') IS NOT NULL THEN
EXECUTE '
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_group_code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
DELETE FROM fifo_stock_v2_flag_groups
WHERE code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
IF to_regclass('public.fifo_stock_v2_flag_groups') IS NOT NULL THEN
EXECUTE '
DELETE FROM fifo_stock_v2_flag_groups
WHERE code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
END $$;
COMMIT;
@@ -1,250 +1,6 @@
BEGIN;
INSERT INTO fifo_stock_v2_flag_groups(code, name, priority)
VALUES
('AYAM', 'AYAM', 10),
('AFKIR_CULLING_MATI', 'AFKIR/CULLING/MATI', 20),
('PAKAN', 'PAKAN', 30),
('OVK', 'OVK', 40),
('TELUR', 'TELUR', 50),
('TELUR_GRADE', 'UTUH/PUTIH/RETAK/PECAH/PAPACAL/JUMBO', 60)
ON CONFLICT (code) DO UPDATE
SET
name = EXCLUDED.name,
priority = EXCLUDED.priority,
updated_at = NOW();
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority)
VALUES
('DOC', 'AYAM', 10),
('PULLET', 'AYAM', 20),
('LAYER', 'AYAM', 30),
('AYAM-AFKIR', 'AFKIR_CULLING_MATI', 10),
('AYAM-CULLING', 'AFKIR_CULLING_MATI', 20),
('AYAM-MATI', 'AFKIR_CULLING_MATI', 30),
('PAKAN', 'PAKAN', 10),
('PRE-STARTER', 'PAKAN', 20),
('STARTER', 'PAKAN', 30),
('FINISHER', 'PAKAN', 40),
('OVK', 'OVK', 10),
('OBAT', 'OVK', 20),
('VITAMIN', 'OVK', 30),
('KIMIA', 'OVK', 40),
('TELUR', 'TELUR', 10),
('TELUR-UTUH', 'TELUR_GRADE', 10),
('TELUR-PUTIH', 'TELUR_GRADE', 20),
('TELUR-RETAK', 'TELUR_GRADE', 30),
('TELUR-PECAH', 'TELUR_GRADE', 40),
('TELUR-PAPACAL', 'TELUR_GRADE', 50),
('TELUR-JUMBO', 'TELUR_GRADE', 60)
ON CONFLICT (flag_name) DO UPDATE
SET
flag_group_code = EXCLUDED.flag_group_code,
priority = EXCLUDED.priority,
updated_at = NOW();
INSERT INTO fifo_stock_v2_traits(
source_table,
lane,
date_table,
date_join_left_col,
date_join_right_col,
date_column,
fallback_date_column,
sort_priority,
id_column
)
VALUES
('purchase_items', 'STOCKABLE', NULL, NULL, NULL, 'received_date', NULL, 10, 'id'),
('stock_transfer_details', 'STOCKABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('stock_transfer_details', 'USABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('laying_transfer_targets', 'STOCKABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('laying_transfer_sources', 'USABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('adjustment_stocks', 'STOCKABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('adjustment_stocks', 'USABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('recording_stocks', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_eggs', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', 'created_at', 40, 'id'),
('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'),
('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id'),
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id')
ON CONFLICT (source_table, lane) DO UPDATE
SET
date_table = EXCLUDED.date_table,
date_join_left_col = EXCLUDED.date_join_left_col,
date_join_right_col = EXCLUDED.date_join_right_col,
date_column = EXCLUDED.date_column,
fallback_date_column = EXCLUDED.fallback_date_column,
sort_priority = EXCLUDED.sort_priority,
id_column = EXCLUDED.id_column,
is_active = TRUE;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
-- AYAM STOCKABLE
('AYAM', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE),
-- AYAM USABLE
('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE),
('AYAM', 'USABLE', 'RECORDING_DEPLETION_OUT', 'recording_depletions', 'id', 'source_product_warehouse_id', 'qty', NULL, 'pending_qty', NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
('AYAM', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfer_sources', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE),
-- AFKIR/CULLING/MATI STOCKABLE
('AFKIR_CULLING_MATI', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'RECORDING_DEPLETION_IN', 'recording_depletions', 'id', 'product_warehouse_id', 'qty', NULL, NULL, NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
-- AFKIR/CULLING/MATI USABLE
('AFKIR_CULLING_MATI', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- PAKAN STOCKABLE
('PAKAN', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('PAKAN', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- PAKAN USABLE
('PAKAN', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('PAKAN', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- OVK STOCKABLE
('OVK', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('OVK', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- OVK USABLE
('OVK', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('OVK', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR STOCKABLE
('TELUR', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('TELUR', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR USABLE
('TELUR', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR_GRADE STOCKABLE
('TELUR_GRADE', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR_GRADE', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('TELUR_GRADE', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR_GRADE USABLE
('TELUR_GRADE', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = EXCLUDED.is_active,
updated_at = NOW();
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, NULL, 'USABLE', TRUE, 999, 'fifo_v2_default_allow', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code IS NULL
AND lane = 'USABLE'
AND reason = 'fifo_v2_default_allow'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'AYAM', 'RECORDING_DEPLETION_OUT', 'USABLE', FALSE, 10, 'fifo_v2_exception_ayam_depletion_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code = 'AYAM'
AND function_code = 'RECORDING_DEPLETION_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_ayam_depletion_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'MARKETING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_marketing_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'STOCK_TRANSFER_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'ADJUSTMENT_OUT', 'USABLE', FALSE, 40, 'fifo_v2_exception_adjustment_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'ADJUSTMENT_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_adjustment_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'TRANSFER_TO_LAYING_OUT', 'USABLE', FALSE, 50, 'fifo_v2_exception_transfer_laying_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_laying_block'
);
-- no-op: moved to 20260306090010_seed_fifo_stock_v2_config_after_core.up.sql
-- to ensure FIFO core tables exist before seeding on fresh migrations.
COMMIT;
@@ -1,13 +1,5 @@
BEGIN;
-- Restore CHICKIN route if rollback is required.
-- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration.
UPDATE fifo_stock_v2_route_rules
SET is_active = TRUE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
-- no-op: moved to 20260306090011_disable_chickin_fifo_consumption_after_core.down.sql
COMMIT;
@@ -1,151 +1,6 @@
BEGIN;
-- Disable CHICKIN as FIFO USABLE so chick-in acts as business tagging/conversion,
-- not physical stock consumption.
UPDATE fifo_stock_v2_route_rules
SET is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins'
AND is_active = TRUE;
-- Release existing active allocations created by PROJECT_CHICKIN
-- and return warehouse qty back.
WITH released AS (
UPDATE stock_allocations
SET status = 'RELEASED',
released_at = COALESCE(released_at, NOW()),
updated_at = NOW(),
note = CASE
WHEN COALESCE(note, '') = '' THEN 'fifo_v2_chickin_conversion_release'
ELSE note || '; fifo_v2_chickin_conversion_release'
END
WHERE usable_type = 'PROJECT_CHICKIN'
AND status = 'ACTIVE'
RETURNING product_warehouse_id, qty
),
pw_delta AS (
SELECT product_warehouse_id, COALESCE(SUM(qty), 0) AS qty_delta
FROM released
GROUP BY product_warehouse_id
)
UPDATE product_warehouses pw
SET qty = COALESCE(pw.qty, 0) + d.qty_delta
FROM pw_delta d
WHERE pw.id = d.product_warehouse_id;
-- Resync stockable total_used columns from remaining ACTIVE allocations.
-- purchase_items (PURCHASE_ITEMS)
UPDATE purchase_items pi
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'PURCHASE_ITEMS'
GROUP BY stockable_id
) a
WHERE pi.id = a.stockable_id;
UPDATE purchase_items pi
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'PURCHASE_ITEMS'
AND sa.stockable_id = pi.id
);
-- stock_transfer_details (STOCK_TRANSFER_IN)
UPDATE stock_transfer_details std
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'STOCK_TRANSFER_IN'
GROUP BY stockable_id
) a
WHERE std.id = a.stockable_id;
UPDATE stock_transfer_details std
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
);
-- adjustment_stocks (ADJUSTMENT_IN)
UPDATE adjustment_stocks ast
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'ADJUSTMENT_IN'
GROUP BY stockable_id
) a
WHERE ast.id = a.stockable_id;
UPDATE adjustment_stocks ast
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'ADJUSTMENT_IN'
AND sa.stockable_id = ast.id
);
-- laying_transfer_targets (TRANSFERTOLAYING_IN)
UPDATE laying_transfer_targets ltt
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'TRANSFERTOLAYING_IN'
GROUP BY stockable_id
) a
WHERE ltt.id = a.stockable_id;
UPDATE laying_transfer_targets ltt
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'TRANSFERTOLAYING_IN'
AND sa.stockable_id = ltt.id
);
-- recording_eggs (RECORDING_EGG)
UPDATE recording_eggs re
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'RECORDING_EGG'
GROUP BY stockable_id
) a
WHERE re.id = a.stockable_id;
UPDATE recording_eggs re
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'RECORDING_EGG'
AND sa.stockable_id = re.id
);
-- no-op: moved to 20260306090011_disable_chickin_fifo_consumption_after_core.up.sql
-- to ensure FIFO core + seed are applied before this data update migration.
COMMIT;
@@ -1,9 +1,5 @@
BEGIN;
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
-- no-op: moved to 20260306090012_fix_fifo_chickin_out_after_seed.down.sql
COMMIT;
@@ -1,34 +1,6 @@
BEGIN;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
updated_at = NOW(),
-- Keep existing is_active (do not override disable migration if it was intentional).
is_active = fifo_stock_v2_route_rules.is_active;
-- no-op: moved to 20260306090012_fix_fifo_chickin_out_after_seed.up.sql
-- to ensure AYAM flag group exists before route-rule upsert.
COMMIT;
@@ -0,0 +1,8 @@
BEGIN;
DROP INDEX IF EXISTS idx_laying_transfers_economic_cutoff_date;
ALTER TABLE laying_transfers
DROP COLUMN IF EXISTS economic_cutoff_date;
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
ALTER TABLE laying_transfers
ADD COLUMN IF NOT EXISTS economic_cutoff_date DATE;
CREATE INDEX IF NOT EXISTS idx_laying_transfers_economic_cutoff_date
ON laying_transfers(economic_cutoff_date);
UPDATE laying_transfers
SET economic_cutoff_date = COALESCE(economic_cutoff_date, effective_move_date, transfer_date)
WHERE economic_cutoff_date IS NULL;
COMMIT;
@@ -0,0 +1,5 @@
BEGIN;
-- no-op: moved to 20260306090013_add_ayam_flag_member_fifo_v2_after_seed.down.sql
COMMIT;
@@ -0,0 +1,6 @@
BEGIN;
-- no-op: moved to 20260306090013_add_ayam_flag_member_fifo_v2_after_seed.up.sql
-- to ensure AYAM flag group exists before inserting AYAM member.
COMMIT;
@@ -0,0 +1,37 @@
BEGIN;
DELETE FROM fifo_stock_v2_overconsume_rules
WHERE reason IN (
'fifo_v2_default_allow',
'fifo_v2_exception_ayam_depletion_block',
'fifo_v2_exception_marketing_block',
'fifo_v2_exception_transfer_block',
'fifo_v2_exception_adjustment_block',
'fifo_v2_exception_transfer_laying_block'
);
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
DELETE FROM fifo_stock_v2_traits
WHERE source_table IN (
'purchase_items',
'stock_transfer_details',
'laying_transfer_targets',
'laying_transfer_sources',
'adjustment_stocks',
'recording_stocks',
'recording_depletions',
'recording_eggs',
'marketing_delivery_products',
'project_chickins',
'project_flock_populations'
);
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
DELETE FROM fifo_stock_v2_flag_groups
WHERE code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
COMMIT;
@@ -0,0 +1,250 @@
BEGIN;
INSERT INTO fifo_stock_v2_flag_groups(code, name, priority)
VALUES
('AYAM', 'AYAM', 10),
('AFKIR_CULLING_MATI', 'AFKIR/CULLING/MATI', 20),
('PAKAN', 'PAKAN', 30),
('OVK', 'OVK', 40),
('TELUR', 'TELUR', 50),
('TELUR_GRADE', 'UTUH/PUTIH/RETAK/PECAH/PAPACAL/JUMBO', 60)
ON CONFLICT (code) DO UPDATE
SET
name = EXCLUDED.name,
priority = EXCLUDED.priority,
updated_at = NOW();
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority)
VALUES
('DOC', 'AYAM', 10),
('PULLET', 'AYAM', 20),
('LAYER', 'AYAM', 30),
('AYAM-AFKIR', 'AFKIR_CULLING_MATI', 10),
('AYAM-CULLING', 'AFKIR_CULLING_MATI', 20),
('AYAM-MATI', 'AFKIR_CULLING_MATI', 30),
('PAKAN', 'PAKAN', 10),
('PRE-STARTER', 'PAKAN', 20),
('STARTER', 'PAKAN', 30),
('FINISHER', 'PAKAN', 40),
('OVK', 'OVK', 10),
('OBAT', 'OVK', 20),
('VITAMIN', 'OVK', 30),
('KIMIA', 'OVK', 40),
('TELUR', 'TELUR', 10),
('TELUR-UTUH', 'TELUR_GRADE', 10),
('TELUR-PUTIH', 'TELUR_GRADE', 20),
('TELUR-RETAK', 'TELUR_GRADE', 30),
('TELUR-PECAH', 'TELUR_GRADE', 40),
('TELUR-PAPACAL', 'TELUR_GRADE', 50),
('TELUR-JUMBO', 'TELUR_GRADE', 60)
ON CONFLICT (flag_name) DO UPDATE
SET
flag_group_code = EXCLUDED.flag_group_code,
priority = EXCLUDED.priority,
updated_at = NOW();
INSERT INTO fifo_stock_v2_traits(
source_table,
lane,
date_table,
date_join_left_col,
date_join_right_col,
date_column,
fallback_date_column,
sort_priority,
id_column
)
VALUES
('purchase_items', 'STOCKABLE', NULL, NULL, NULL, 'received_date', NULL, 10, 'id'),
('stock_transfer_details', 'STOCKABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('stock_transfer_details', 'USABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('laying_transfer_targets', 'STOCKABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('laying_transfer_sources', 'USABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('adjustment_stocks', 'STOCKABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('adjustment_stocks', 'USABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('recording_stocks', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_eggs', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', 'created_at', 40, 'id'),
('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'),
('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id'),
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id')
ON CONFLICT (source_table, lane) DO UPDATE
SET
date_table = EXCLUDED.date_table,
date_join_left_col = EXCLUDED.date_join_left_col,
date_join_right_col = EXCLUDED.date_join_right_col,
date_column = EXCLUDED.date_column,
fallback_date_column = EXCLUDED.fallback_date_column,
sort_priority = EXCLUDED.sort_priority,
id_column = EXCLUDED.id_column,
is_active = TRUE;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
-- AYAM STOCKABLE
('AYAM', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE),
-- AYAM USABLE
('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE),
('AYAM', 'USABLE', 'RECORDING_DEPLETION_OUT', 'recording_depletions', 'id', 'source_product_warehouse_id', 'qty', NULL, 'pending_qty', NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
('AYAM', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfer_sources', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE),
-- AFKIR/CULLING/MATI STOCKABLE
('AFKIR_CULLING_MATI', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'RECORDING_DEPLETION_IN', 'recording_depletions', 'id', 'product_warehouse_id', 'qty', NULL, NULL, NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
-- AFKIR/CULLING/MATI USABLE
('AFKIR_CULLING_MATI', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- PAKAN STOCKABLE
('PAKAN', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('PAKAN', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- PAKAN USABLE
('PAKAN', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('PAKAN', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- OVK STOCKABLE
('OVK', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('OVK', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- OVK USABLE
('OVK', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('OVK', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR STOCKABLE
('TELUR', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('TELUR', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR USABLE
('TELUR', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR_GRADE STOCKABLE
('TELUR_GRADE', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR_GRADE', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('TELUR_GRADE', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR_GRADE USABLE
('TELUR_GRADE', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = EXCLUDED.is_active,
updated_at = NOW();
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, NULL, 'USABLE', TRUE, 999, 'fifo_v2_default_allow', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code IS NULL
AND lane = 'USABLE'
AND reason = 'fifo_v2_default_allow'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'AYAM', 'RECORDING_DEPLETION_OUT', 'USABLE', FALSE, 10, 'fifo_v2_exception_ayam_depletion_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code = 'AYAM'
AND function_code = 'RECORDING_DEPLETION_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_ayam_depletion_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'MARKETING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_marketing_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'STOCK_TRANSFER_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'ADJUSTMENT_OUT', 'USABLE', FALSE, 40, 'fifo_v2_exception_adjustment_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'ADJUSTMENT_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_adjustment_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'TRANSFER_TO_LAYING_OUT', 'USABLE', FALSE, 50, 'fifo_v2_exception_transfer_laying_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_laying_block'
);
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
-- Restore CHICKIN route if rollback is required.
-- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration.
UPDATE fifo_stock_v2_route_rules
SET is_active = TRUE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT;
@@ -0,0 +1,151 @@
BEGIN;
-- Disable CHICKIN as FIFO USABLE so chick-in acts as business tagging/conversion,
-- not physical stock consumption.
UPDATE fifo_stock_v2_route_rules
SET is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins'
AND is_active = TRUE;
-- Release existing active allocations created by PROJECT_CHICKIN
-- and return warehouse qty back.
WITH released AS (
UPDATE stock_allocations
SET status = 'RELEASED',
released_at = COALESCE(released_at, NOW()),
updated_at = NOW(),
note = CASE
WHEN COALESCE(note, '') = '' THEN 'fifo_v2_chickin_conversion_release'
ELSE note || '; fifo_v2_chickin_conversion_release'
END
WHERE usable_type = 'PROJECT_CHICKIN'
AND status = 'ACTIVE'
RETURNING product_warehouse_id, qty
),
pw_delta AS (
SELECT product_warehouse_id, COALESCE(SUM(qty), 0) AS qty_delta
FROM released
GROUP BY product_warehouse_id
)
UPDATE product_warehouses pw
SET qty = COALESCE(pw.qty, 0) + d.qty_delta
FROM pw_delta d
WHERE pw.id = d.product_warehouse_id;
-- Resync stockable total_used columns from remaining ACTIVE allocations.
-- purchase_items (PURCHASE_ITEMS)
UPDATE purchase_items pi
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'PURCHASE_ITEMS'
GROUP BY stockable_id
) a
WHERE pi.id = a.stockable_id;
UPDATE purchase_items pi
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'PURCHASE_ITEMS'
AND sa.stockable_id = pi.id
);
-- stock_transfer_details (STOCK_TRANSFER_IN)
UPDATE stock_transfer_details std
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'STOCK_TRANSFER_IN'
GROUP BY stockable_id
) a
WHERE std.id = a.stockable_id;
UPDATE stock_transfer_details std
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
);
-- adjustment_stocks (ADJUSTMENT_IN)
UPDATE adjustment_stocks ast
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'ADJUSTMENT_IN'
GROUP BY stockable_id
) a
WHERE ast.id = a.stockable_id;
UPDATE adjustment_stocks ast
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'ADJUSTMENT_IN'
AND sa.stockable_id = ast.id
);
-- laying_transfer_targets (TRANSFERTOLAYING_IN)
UPDATE laying_transfer_targets ltt
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'TRANSFERTOLAYING_IN'
GROUP BY stockable_id
) a
WHERE ltt.id = a.stockable_id;
UPDATE laying_transfer_targets ltt
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'TRANSFERTOLAYING_IN'
AND sa.stockable_id = ltt.id
);
-- recording_eggs (RECORDING_EGG)
UPDATE recording_eggs re
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'RECORDING_EGG'
GROUP BY stockable_id
) a
WHERE re.id = a.stockable_id;
UPDATE recording_eggs re
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'RECORDING_EGG'
AND sa.stockable_id = re.id
);
COMMIT;
@@ -0,0 +1,9 @@
BEGIN;
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT;
@@ -0,0 +1,34 @@
BEGIN;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
updated_at = NOW(),
-- Keep existing is_active (do not override disable migration if it was intentional).
is_active = fifo_stock_v2_route_rules.is_active;
COMMIT;
@@ -0,0 +1,7 @@
BEGIN;
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_name = 'AYAM'
AND flag_group_code = 'AYAM';
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority, is_active, created_at, updated_at)
VALUES
('AYAM', 'AYAM', 5, TRUE, NOW(), NOW())
ON CONFLICT (flag_name) DO UPDATE
SET
flag_group_code = EXCLUDED.flag_group_code,
priority = EXCLUDED.priority,
is_active = TRUE,
updated_at = NOW();
COMMIT;
@@ -0,0 +1,60 @@
BEGIN;
UPDATE fifo_stock_v2_route_rules
SET
is_active = TRUE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND source_table = 'laying_transfer_sources';
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND source_table = 'laying_transfers';
UPDATE fifo_stock_v2_traits
SET is_active = TRUE
WHERE source_table = 'laying_transfer_sources'
AND lane = 'USABLE';
UPDATE fifo_stock_v2_traits
SET is_active = FALSE
WHERE source_table = 'laying_transfers'
AND lane = 'USABLE';
DROP INDEX IF EXISTS idx_laying_transfers_source_project_flock_kandang_id;
DROP INDEX IF EXISTS idx_laying_transfers_source_product_warehouse_id;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_laying_transfers_source_project_flock_kandang_id'
) THEN
ALTER TABLE laying_transfers
DROP CONSTRAINT fk_laying_transfers_source_project_flock_kandang_id;
END IF;
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_laying_transfers_source_product_warehouse_id'
) THEN
ALTER TABLE laying_transfers
DROP CONSTRAINT fk_laying_transfers_source_product_warehouse_id;
END IF;
END $$;
ALTER TABLE laying_transfers
DROP COLUMN IF EXISTS source_project_flock_kandang_id,
DROP COLUMN IF EXISTS source_product_warehouse_id,
DROP COLUMN IF EXISTS source_requested_qty,
DROP COLUMN IF EXISTS source_usage_qty,
DROP COLUMN IF EXISTS source_pending_usage_qty;
COMMIT;
@@ -0,0 +1,170 @@
BEGIN;
ALTER TABLE laying_transfers
ADD COLUMN IF NOT EXISTS source_project_flock_kandang_id BIGINT,
ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT,
ADD COLUMN IF NOT EXISTS source_requested_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS source_usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS source_pending_usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_laying_transfers_source_project_flock_kandang_id'
) THEN
ALTER TABLE laying_transfers
ADD CONSTRAINT fk_laying_transfers_source_project_flock_kandang_id
FOREIGN KEY (source_project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_laying_transfers_source_product_warehouse_id'
) THEN
ALTER TABLE laying_transfers
ADD CONSTRAINT fk_laying_transfers_source_product_warehouse_id
FOREIGN KEY (source_product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_laying_transfers_source_project_flock_kandang_id
ON laying_transfers(source_project_flock_kandang_id);
CREATE INDEX IF NOT EXISTS idx_laying_transfers_source_product_warehouse_id
ON laying_transfers(source_product_warehouse_id);
WITH single_source AS (
SELECT
lts.laying_transfer_id,
MIN(lts.source_project_flock_kandang_id) AS source_project_flock_kandang_id,
MIN(lts.product_warehouse_id) AS source_product_warehouse_id
FROM laying_transfer_sources lts
WHERE lts.deleted_at IS NULL
GROUP BY lts.laying_transfer_id
HAVING COUNT(*) = 1
)
UPDATE laying_transfers lt
SET
source_project_flock_kandang_id = ss.source_project_flock_kandang_id,
source_product_warehouse_id = ss.source_product_warehouse_id
FROM single_source ss
WHERE lt.id = ss.laying_transfer_id
AND (lt.source_project_flock_kandang_id IS NULL OR lt.source_project_flock_kandang_id = 0);
WITH source_totals AS (
SELECT
laying_transfer_id,
COALESCE(SUM(requested_qty), 0) AS requested_qty,
COALESCE(SUM(usage_qty), 0) AS usage_qty,
COALESCE(SUM(pending_usage_qty), 0) AS pending_qty
FROM laying_transfer_sources
WHERE deleted_at IS NULL
GROUP BY laying_transfer_id
)
UPDATE laying_transfers lt
SET
source_requested_qty = CASE
WHEN lt.source_requested_qty = 0 THEN st.requested_qty
ELSE lt.source_requested_qty
END,
source_usage_qty = CASE
WHEN lt.source_usage_qty = 0 THEN st.usage_qty
ELSE lt.source_usage_qty
END,
source_pending_usage_qty = CASE
WHEN lt.source_pending_usage_qty = 0 THEN st.pending_qty
ELSE lt.source_pending_usage_qty
END
FROM source_totals st
WHERE lt.id = st.laying_transfer_id;
WITH target_totals AS (
SELECT
laying_transfer_id,
COALESCE(SUM(total_qty), 0) AS total_qty
FROM laying_transfer_targets
WHERE deleted_at IS NULL
GROUP BY laying_transfer_id
)
UPDATE laying_transfers lt
SET source_requested_qty = tt.total_qty
FROM target_totals tt
WHERE lt.id = tt.laying_transfer_id
AND (lt.source_requested_qty IS NULL OR lt.source_requested_qty = 0);
INSERT INTO fifo_stock_v2_traits(
source_table,
lane,
date_table,
date_join_left_col,
date_join_right_col,
date_column,
fallback_date_column,
sort_priority,
id_column
)
VALUES
('laying_transfers', 'USABLE', NULL, NULL, NULL, 'transfer_date', NULL, 25, 'id')
ON CONFLICT (source_table, lane) DO UPDATE
SET
date_table = EXCLUDED.date_table,
date_join_left_col = EXCLUDED.date_join_left_col,
date_join_right_col = EXCLUDED.date_join_right_col,
date_column = EXCLUDED.date_column,
fallback_date_column = EXCLUDED.fallback_date_column,
sort_priority = EXCLUDED.sort_priority,
id_column = EXCLUDED.id_column,
is_active = TRUE;
UPDATE fifo_stock_v2_traits
SET is_active = FALSE
WHERE source_table = 'laying_transfer_sources'
AND lane = 'USABLE';
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfers', 'id', 'source_product_warehouse_id', 'source_usage_qty', NULL, 'source_pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = TRUE,
updated_at = NOW();
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND source_table = 'laying_transfer_sources';
COMMIT;
@@ -0,0 +1,18 @@
BEGIN;
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'STOCKABLE'
AND function_code = 'POPULATION_IN'
AND source_table = 'project_flock_populations'
AND legacy_type_key = 'PROJECT_FLOCK_POPULATION';
UPDATE fifo_stock_v2_traits
SET is_active = FALSE
WHERE source_table = 'project_flock_populations'
AND lane = 'STOCKABLE';
COMMIT;
@@ -0,0 +1,81 @@
BEGIN;
INSERT INTO fifo_stock_v2_traits(
source_table,
lane,
date_table,
date_join_left_col,
date_join_right_col,
date_column,
fallback_date_column,
sort_priority,
id_column,
is_active
)
VALUES
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id', TRUE)
ON CONFLICT (source_table, lane) DO UPDATE
SET
date_table = EXCLUDED.date_table,
date_join_left_col = EXCLUDED.date_join_left_col,
date_join_right_col = EXCLUDED.date_join_right_col,
date_column = EXCLUDED.date_column,
fallback_date_column = EXCLUDED.fallback_date_column,
sort_priority = EXCLUDED.sort_priority,
id_column = EXCLUDED.id_column,
is_active = TRUE;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = TRUE,
updated_at = NOW();
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id;
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.stockable_id = p.id
);
COMMIT;
@@ -0,0 +1,28 @@
BEGIN;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS fk_daily_checklists_kandang;
UPDATE daily_checklists dc
SET kandang_id = k.id
FROM kandangs k
WHERE
dc.kandang_id = k.kandang_group_id;
ALTER TABLE daily_checklists
ADD CONSTRAINT fk_daily_checklists_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs (id) ON DELETE CASCADE;
DROP INDEX IF EXISTS idx_kandangs_kandang_group_id;
ALTER TABLE kandangs
DROP CONSTRAINT IF EXISTS fk_kandangs_kandang_group;
ALTER TABLE kandangs
DROP COLUMN IF EXISTS kandang_group_id;
DROP INDEX IF EXISTS kandang_groups_name_unique;
DROP TABLE IF EXISTS kandang_groups;
COMMIT;
@@ -0,0 +1,91 @@
BEGIN;
CREATE TABLE kandang_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL,
location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE,
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX kandang_groups_name_unique ON kandang_groups (name)
WHERE
deleted_at IS NULL;
ALTER TABLE kandangs
ADD COLUMN kandang_group_id BIGINT;
CREATE TEMP TABLE tmp_kandang_group_map (
kandang_id BIGINT PRIMARY KEY,
kandang_group_id BIGINT NOT NULL
) ON COMMIT DROP;
INSERT INTO tmp_kandang_group_map (kandang_id, kandang_group_id)
SELECT
k.id,
nextval(pg_get_serial_sequence('kandang_groups', 'id'))
FROM kandangs k
ORDER BY
k.id;
INSERT INTO kandang_groups (
id,
name,
status,
location_id,
pic_id,
created_at,
updated_at,
deleted_at,
created_by
)
SELECT
m.kandang_group_id,
k.name,
k.status,
k.location_id,
CASE WHEN pic.id IS NOT NULL THEN k.pic_id ELSE NULL END,
k.created_at,
k.updated_at,
k.deleted_at,
CASE WHEN creator.id IS NOT NULL THEN k.created_by ELSE NULL END
FROM kandangs k
JOIN tmp_kandang_group_map m ON m.kandang_id = k.id
LEFT JOIN users pic ON pic.id = k.pic_id
LEFT JOIN users creator ON creator.id = k.created_by
ORDER BY
k.id;
UPDATE kandangs k
SET kandang_group_id = m.kandang_group_id
FROM tmp_kandang_group_map m
WHERE
m.kandang_id = k.id;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS fk_daily_checklists_kandang;
UPDATE daily_checklists dc
SET kandang_id = m.kandang_group_id
FROM tmp_kandang_group_map m
WHERE
dc.kandang_id = m.kandang_id;
ALTER TABLE daily_checklists
ADD CONSTRAINT fk_daily_checklists_kandang
FOREIGN KEY (kandang_id) REFERENCES kandang_groups (id) ON DELETE CASCADE;
ALTER TABLE kandangs
ALTER COLUMN kandang_group_id SET NOT NULL;
ALTER TABLE kandangs
ADD CONSTRAINT fk_kandangs_kandang_group
FOREIGN KEY (kandang_group_id) REFERENCES kandang_groups (id) ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX idx_kandangs_kandang_group_id ON kandangs (kandang_group_id);
COMMIT;
@@ -0,0 +1,12 @@
BEGIN;
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT;
@@ -0,0 +1,33 @@
BEGIN;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = TRUE,
updated_at = NOW();
COMMIT;
@@ -0,0 +1,54 @@
BEGIN;
ALTER TABLE employee_kandangs
DROP CONSTRAINT IF EXISTS fk_employee_kandangs_kandang;
ALTER TABLE employee_kandangs
DROP CONSTRAINT IF EXISTS uq_employee_kandangs;
CREATE TEMP TABLE tmp_kandang_group_to_kandang_map (
kandang_group_id BIGINT PRIMARY KEY,
kandang_id BIGINT NOT NULL
) ON COMMIT DROP;
INSERT INTO tmp_kandang_group_to_kandang_map (kandang_group_id, kandang_id)
SELECT
k.kandang_group_id,
MIN(k.id) AS kandang_id
FROM kandangs k
WHERE k.kandang_group_id IS NOT NULL
GROUP BY k.kandang_group_id;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM employee_kandangs ek
LEFT JOIN tmp_kandang_group_to_kandang_map m ON m.kandang_group_id = ek.kandang_id
WHERE m.kandang_id IS NULL
) THEN
RAISE EXCEPTION 'Cannot rollback employee_kandangs migration: kandang_group_id has no kandang mapping';
END IF;
END $$;
UPDATE employee_kandangs ek
SET kandang_id = m.kandang_id,
updated_at = NOW()
FROM tmp_kandang_group_to_kandang_map m
WHERE m.kandang_group_id = ek.kandang_id;
DELETE FROM employee_kandangs ek1
USING employee_kandangs ek2
WHERE ek1.id > ek2.id
AND ek1.employee_id = ek2.employee_id
AND ek1.kandang_id = ek2.kandang_id;
ALTER TABLE employee_kandangs
ADD CONSTRAINT fk_employee_kandangs_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs (id)
ON DELETE CASCADE;
ALTER TABLE employee_kandangs
ADD CONSTRAINT uq_employee_kandangs UNIQUE (employee_id, kandang_id);
COMMIT;
@@ -0,0 +1,41 @@
BEGIN;
ALTER TABLE employee_kandangs
DROP CONSTRAINT IF EXISTS fk_employee_kandangs_kandang;
ALTER TABLE employee_kandangs
DROP CONSTRAINT IF EXISTS uq_employee_kandangs;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM employee_kandangs ek
LEFT JOIN kandangs k ON k.id = ek.kandang_id
WHERE k.kandang_group_id IS NULL
) THEN
RAISE EXCEPTION 'Cannot migrate employee_kandangs: kandang_id has no kandang_group_id mapping';
END IF;
END $$;
UPDATE employee_kandangs ek
SET kandang_id = k.kandang_group_id,
updated_at = NOW()
FROM kandangs k
WHERE k.id = ek.kandang_id;
DELETE FROM employee_kandangs ek1
USING employee_kandangs ek2
WHERE ek1.id > ek2.id
AND ek1.employee_id = ek2.employee_id
AND ek1.kandang_id = ek2.kandang_id;
ALTER TABLE employee_kandangs
ADD CONSTRAINT fk_employee_kandangs_kandang
FOREIGN KEY (kandang_id) REFERENCES kandang_groups (id)
ON DELETE CASCADE;
ALTER TABLE employee_kandangs
ADD CONSTRAINT uq_employee_kandangs UNIQUE (employee_id, kandang_id);
COMMIT;
@@ -0,0 +1,118 @@
BEGIN;
-- MARKETING_OUT: if AYAM-only rule exists, convert back to global rule.
UPDATE fifo_stock_v2_overconsume_rules
SET
flag_group_code = NULL,
allow_overconsume = FALSE,
priority = 20,
reason = 'fifo_v2_exception_marketing_block',
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_marketing_block_ayam_only';
-- MARKETING_OUT: if global row already exists, keep it active.
UPDATE fifo_stock_v2_overconsume_rules
SET
allow_overconsume = FALSE,
priority = 20,
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_marketing_block';
-- MARKETING_OUT: insert global rule if still missing.
INSERT INTO fifo_stock_v2_overconsume_rules(
flag_group_code,
function_code,
lane,
allow_overconsume,
priority,
reason,
is_active
)
SELECT NULL, 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block', TRUE
WHERE NOT EXISTS (
SELECT 1
FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_marketing_block'
);
-- MARKETING_OUT: deactivate AYAM-only duplicates if any remain.
UPDATE fifo_stock_v2_overconsume_rules
SET
is_active = FALSE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_marketing_block_ayam_only';
-- STOCK_TRANSFER_OUT: if AYAM-only rule exists, convert back to global rule.
UPDATE fifo_stock_v2_overconsume_rules
SET
flag_group_code = NULL,
allow_overconsume = FALSE,
priority = 30,
reason = 'fifo_v2_exception_transfer_block',
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_transfer_block_ayam_only';
-- STOCK_TRANSFER_OUT: if global row already exists, keep it active.
UPDATE fifo_stock_v2_overconsume_rules
SET
allow_overconsume = FALSE,
priority = 30,
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_transfer_block';
-- STOCK_TRANSFER_OUT: insert global rule if still missing.
INSERT INTO fifo_stock_v2_overconsume_rules(
flag_group_code,
function_code,
lane,
allow_overconsume,
priority,
reason,
is_active
)
SELECT NULL, 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block', TRUE
WHERE NOT EXISTS (
SELECT 1
FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_transfer_block'
);
-- STOCK_TRANSFER_OUT: deactivate AYAM-only duplicates if any remain.
UPDATE fifo_stock_v2_overconsume_rules
SET
is_active = FALSE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_transfer_block_ayam_only';
-- CHICKIN_OUT: rollback AYAM-only hard-block added by up migration.
UPDATE fifo_stock_v2_overconsume_rules
SET
is_active = FALSE
WHERE lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_chickin_block_ayam_only';
COMMIT;
@@ -0,0 +1,139 @@
BEGIN;
-- MARKETING_OUT: if global rule exists, convert to AYAM-specific.
UPDATE fifo_stock_v2_overconsume_rules
SET
flag_group_code = 'AYAM',
allow_overconsume = FALSE,
priority = 20,
reason = 'fifo_v2_exception_marketing_block_ayam_only',
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_marketing_block';
-- MARKETING_OUT: if AYAM-specific row already exists, enforce desired value.
UPDATE fifo_stock_v2_overconsume_rules
SET
allow_overconsume = FALSE,
priority = 20,
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_marketing_block_ayam_only';
-- MARKETING_OUT: insert AYAM-specific if no suitable row exists.
INSERT INTO fifo_stock_v2_overconsume_rules(
flag_group_code,
function_code,
lane,
allow_overconsume,
priority,
reason,
is_active
)
SELECT 'AYAM', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_ayam_only', TRUE
WHERE NOT EXISTS (
SELECT 1
FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_marketing_block_ayam_only'
);
-- MARKETING_OUT: deactivate remaining global rule (if any duplicate row exists).
UPDATE fifo_stock_v2_overconsume_rules
SET
is_active = FALSE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_marketing_block';
-- STOCK_TRANSFER_OUT: if global rule exists, convert to AYAM-specific.
UPDATE fifo_stock_v2_overconsume_rules
SET
flag_group_code = 'AYAM',
allow_overconsume = FALSE,
priority = 30,
reason = 'fifo_v2_exception_transfer_block_ayam_only',
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_transfer_block';
-- STOCK_TRANSFER_OUT: if AYAM-specific row already exists, enforce desired value.
UPDATE fifo_stock_v2_overconsume_rules
SET
allow_overconsume = FALSE,
priority = 30,
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_transfer_block_ayam_only';
-- STOCK_TRANSFER_OUT: insert AYAM-specific if no suitable row exists.
INSERT INTO fifo_stock_v2_overconsume_rules(
flag_group_code,
function_code,
lane,
allow_overconsume,
priority,
reason,
is_active
)
SELECT 'AYAM', 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block_ayam_only', TRUE
WHERE NOT EXISTS (
SELECT 1
FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_transfer_block_ayam_only'
);
-- STOCK_TRANSFER_OUT: deactivate remaining global rule (if any duplicate row exists).
UPDATE fifo_stock_v2_overconsume_rules
SET
is_active = FALSE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_transfer_block';
-- CHICKIN_OUT: enforce AYAM-specific hard-block (cannot pending).
UPDATE fifo_stock_v2_overconsume_rules
SET
allow_overconsume = FALSE,
priority = 25,
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_chickin_block_ayam_only';
INSERT INTO fifo_stock_v2_overconsume_rules(
flag_group_code,
function_code,
lane,
allow_overconsume,
priority,
reason,
is_active
)
SELECT 'AYAM', 'CHICKIN_OUT', 'USABLE', FALSE, 25, 'fifo_v2_exception_chickin_block_ayam_only', TRUE
WHERE NOT EXISTS (
SELECT 1
FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_chickin_block_ayam_only'
);
COMMIT;
@@ -0,0 +1,14 @@
BEGIN;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS chk_adjustment_stocks_paired_not_self;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_paired_adjustment_id;
DROP INDEX IF EXISTS idx_adjustment_stocks_paired_adjustment_id;
ALTER TABLE adjustment_stocks
DROP COLUMN IF EXISTS paired_adjustment_id;
COMMIT;
@@ -0,0 +1,86 @@
BEGIN;
ALTER TABLE adjustment_stocks
ADD COLUMN IF NOT EXISTS paired_adjustment_id BIGINT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_adjustment_stocks_paired_adjustment_id'
) THEN
ALTER TABLE adjustment_stocks
ADD CONSTRAINT fk_adjustment_stocks_paired_adjustment_id
FOREIGN KEY (paired_adjustment_id)
REFERENCES adjustment_stocks(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS chk_adjustment_stocks_paired_not_self;
ALTER TABLE adjustment_stocks
ADD CONSTRAINT chk_adjustment_stocks_paired_not_self
CHECK (paired_adjustment_id IS NULL OR paired_adjustment_id <> id);
CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_paired_adjustment_id
ON adjustment_stocks(paired_adjustment_id);
-- Backfill pairing untuk depletion-out <-> depletion-in existing records.
WITH candidates AS (
SELECT
src.id AS src_id,
dst.id AS dst_id,
ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) AS ts_diff,
ABS(dst.id - src.id) AS id_diff,
ROW_NUMBER() OVER (
PARTITION BY src.id
ORDER BY ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) ASC,
ABS(dst.id - src.id) ASC,
dst.id ASC
) AS rn_src,
ROW_NUMBER() OVER (
PARTITION BY dst.id
ORDER BY ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) ASC,
ABS(dst.id - src.id) ASC,
src.id ASC
) AS rn_dst
FROM adjustment_stocks src
JOIN adjustment_stocks dst
ON dst.id <> src.id
AND dst.transaction_type = src.transaction_type
AND dst.function_code = 'RECORDING_DEPLETION_IN'
AND src.function_code = 'RECORDING_DEPLETION_OUT'
AND dst.paired_adjustment_id IS NULL
AND src.paired_adjustment_id IS NULL
AND ABS((COALESCE(src.usage_qty, 0) + COALESCE(src.pending_qty, 0)) - COALESCE(dst.total_qty, 0)) < 0.0001
AND COALESCE(src.price, 0) = COALESCE(dst.price, 0)
AND COALESCE(src.grand_total, 0) = COALESCE(dst.grand_total, 0)
AND ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) <= 120
),
chosen AS (
SELECT src_id, dst_id
FROM candidates
WHERE rn_src = 1
AND rn_dst = 1
)
UPDATE adjustment_stocks src
SET paired_adjustment_id = c.dst_id
FROM chosen c
WHERE src.id = c.src_id
AND src.paired_adjustment_id IS NULL;
WITH chosen AS (
SELECT a.id AS src_id, a.paired_adjustment_id AS dst_id
FROM adjustment_stocks a
WHERE a.function_code = 'RECORDING_DEPLETION_OUT'
AND a.paired_adjustment_id IS NOT NULL
)
UPDATE adjustment_stocks dst
SET paired_adjustment_id = c.src_id
FROM chosen c
WHERE dst.id = c.dst_id
AND dst.paired_adjustment_id IS NULL;
COMMIT;
@@ -0,0 +1,18 @@
BEGIN;
DROP INDEX IF EXISTS idx_recording_depletions_source_project_flock_kandang_id;
DROP INDEX IF EXISTS idx_recording_eggs_project_flock_kandang_id;
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS fk_recording_depletions_source_project_flock_kandang_id;
ALTER TABLE recording_eggs
DROP CONSTRAINT IF EXISTS fk_recording_eggs_project_flock_kandang_id;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS source_project_flock_kandang_id;
ALTER TABLE recording_eggs
DROP COLUMN IF EXISTS project_flock_kandang_id;
COMMIT;
@@ -0,0 +1,61 @@
BEGIN;
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS source_project_flock_kandang_id BIGINT NULL;
ALTER TABLE recording_eggs
ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_recording_depletions_source_project_flock_kandang_id'
) THEN
ALTER TABLE recording_depletions
ADD CONSTRAINT fk_recording_depletions_source_project_flock_kandang_id
FOREIGN KEY (source_project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_recording_eggs_project_flock_kandang_id'
) THEN
ALTER TABLE recording_eggs
ADD CONSTRAINT fk_recording_eggs_project_flock_kandang_id
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_recording_depletions_source_project_flock_kandang_id
ON recording_depletions(source_project_flock_kandang_id);
CREATE INDEX IF NOT EXISTS idx_recording_eggs_project_flock_kandang_id
ON recording_eggs(project_flock_kandang_id);
UPDATE recording_depletions rd
SET source_project_flock_kandang_id = r.project_flock_kandangs_id
FROM recordings r
WHERE r.id = rd.recording_id
AND rd.source_project_flock_kandang_id IS NULL
AND r.project_flock_kandangs_id IS NOT NULL;
UPDATE recording_eggs re
SET project_flock_kandang_id = r.project_flock_kandangs_id
FROM recordings r
WHERE r.id = re.recording_id
AND re.project_flock_kandang_id IS NULL
AND r.project_flock_kandangs_id IS NOT NULL;
COMMIT;
@@ -0,0 +1,9 @@
BEGIN;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
ALTER TABLE daily_checklists
ADD CONSTRAINT daily_checklists_date_kandang_category_key
UNIQUE (date, kandang_id, category);
COMMIT;
@@ -0,0 +1,10 @@
BEGIN;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected
ON daily_checklists (date, kandang_id, category)
WHERE (status IS NULL OR status <> 'REJECTED');
COMMIT;
@@ -0,0 +1,2 @@
ALTER TABLE purchase_items
ALTER COLUMN vehicle_number TYPE VARCHAR(10) USING LEFT(vehicle_number, 10);
@@ -0,0 +1,2 @@
ALTER TABLE purchase_items
ALTER COLUMN vehicle_number TYPE VARCHAR(15) USING vehicle_number;
+2
View File
@@ -5,6 +5,7 @@ import "time"
type AdjustmentStock struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
PairedAdjustmentId *uint `gorm:"column:paired_adjustment_id"`
TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"`
FunctionCode string `gorm:"column:function_code;type:varchar(64)"`
TotalQty float64 `gorm:"column:total_qty;default:0"`
@@ -18,5 +19,6 @@ type AdjustmentStock struct {
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
PairedAdjustment *AdjustmentStock `gorm:"foreignKey:PairedAdjustmentId;references:Id"`
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
}
+1 -1
View File
@@ -17,7 +17,7 @@ type DailyChecklist struct {
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
+2 -2
View File
@@ -26,6 +26,6 @@ type EmployeeKandang struct {
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
Employee Employee `gorm:"foreignKey:EmployeeId;references:Id"`
Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"`
Employee Employee `gorm:"foreignKey:EmployeeId;references:Id"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
}
+2
View File
@@ -11,6 +11,7 @@ type Kandang struct {
Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"`
Status string `gorm:"type:varchar(50);not null"`
LocationId uint `gorm:"not null"`
KandangGroupId uint `gorm:"not null"`
Capacity float64 `gorm:"not null"`
PicId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"`
@@ -19,6 +20,7 @@ type Kandang struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"`
KandangGroup KandangGroup `gorm:"foreignKey:KandangGroupId;references:Id"`
Pic User `gorm:"foreignKey:PicId;references:Id"`
Warehouses []Warehouse `gorm:"foreignKey:KandangId;references:Id"`
ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"`
+24
View File
@@ -0,0 +1,24 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type KandangGroup struct {
Id uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandang_groups_name_unique,where:deleted_at IS NULL"`
Status string `gorm:"type:varchar(50);not null"`
LocationId uint `gorm:"not null"`
PicId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"`
Pic User `gorm:"foreignKey:PicId;references:Id"`
Kandangs []Kandang `gorm:"foreignKey:KandangGroupId;references:Id"`
}
+28 -20
View File
@@ -7,25 +7,33 @@ import (
)
type LayingTransfer struct {
Id uint `gorm:"primaryKey"`
TransferNumber string `gorm:"uniqueIndex;not null"`
FromProjectFlockId uint `gorm:"not null"`
ToProjectFlockId uint `gorm:"not null"`
TransferDate time.Time `gorm:"type:date;not null"`
EffectiveMoveDate *time.Time `gorm:"type:date"`
ExecutedAt *time.Time `gorm:"type:timestamptz"`
ExecutedBy *uint `gorm:"index"`
Notes string `gorm:"type:text"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
Id uint `gorm:"primaryKey"`
TransferNumber string `gorm:"uniqueIndex;not null"`
FromProjectFlockId uint `gorm:"not null"`
ToProjectFlockId uint `gorm:"not null"`
SourceProjectFlockKandangId *uint `gorm:"index"`
SourceProductWarehouseId *uint `gorm:"index"`
SourceRequestedQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
SourceUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
SourcePendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
TransferDate time.Time `gorm:"type:date;not null"`
EconomicCutoffDate *time.Time `gorm:"type:date"`
EffectiveMoveDate *time.Time `gorm:"type:date"`
ExecutedAt *time.Time `gorm:"type:timestamptz"`
ExecutedBy *uint `gorm:"index"`
Notes string `gorm:"type:text"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"`
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"`
SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"`
}
+12 -10
View File
@@ -5,20 +5,22 @@ import (
)
type MarketingDeliveryProduct struct {
Id uint `gorm:"primaryKey;autoIncrement"`
MarketingProductId uint `gorm:"uniqueIndex;not null"`
ProductWarehouseId uint `gorm:"not null"`
UnitPrice float64 `gorm:"type:numeric(15,3)"`
TotalWeight float64 `gorm:"type:numeric(15,3)"`
AvgWeight float64 `gorm:"type:numeric(15,3)"`
TotalPrice float64 `gorm:"type:numeric(15,3)"`
DeliveryDate *time.Time `gorm:"type:timestamptz"`
VehicleNumber string `gorm:"type:varchar(50)"`
Id uint `gorm:"primaryKey;autoIncrement"`
MarketingProductId uint `gorm:"uniqueIndex;not null"`
ProductWarehouseId uint `gorm:"not null"`
AttributedProjectFlockKandangId *uint `gorm:"->;column:attributed_project_flock_kandang_id"`
UnitPrice float64 `gorm:"type:numeric(15,3)"`
TotalWeight float64 `gorm:"type:numeric(15,3)"`
AvgWeight float64 `gorm:"type:numeric(15,3)"`
TotalPrice float64 `gorm:"type:numeric(15,3)"`
DeliveryDate *time.Time `gorm:"type:timestamptz"`
VehicleNumber string `gorm:"type:varchar(50)"`
// FIFO Fields
UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
CreatedAt *time.Time `gorm:"type:timestamptz;not null"`
MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"`
MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"`
AttributedProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:AttributedProjectFlockKandangId;references:Id"`
}
+6 -5
View File
@@ -1,11 +1,12 @@
package entities
type ProductWarehouse struct {
Id uint `gorm:"primaryKey;column:id"`
ProductId uint `gorm:"column:product_id;not null"`
WarehouseId uint `gorm:"column:warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
Id uint `gorm:"primaryKey;column:id"`
ProductId uint `gorm:"column:product_id;not null"`
WarehouseId uint `gorm:"column:warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
AvailableQty *float64 `gorm:"-"`
// Relations
Product Product `gorm:"foreignKey:ProductId;references:Id"`
+4
View File
@@ -43,4 +43,8 @@ type Recording struct {
StandardEggMass *float64 `gorm:"-"`
StandardEggWeight *float64 `gorm:"-"`
StandardFcr *float64 `gorm:"-"`
PopulationCanChange *bool `gorm:"-"`
TransferExecuted *bool `gorm:"-"`
IsTransition *bool `gorm:"-"`
IsLaying *bool `gorm:"-"`
}
+11 -9
View File
@@ -1,14 +1,16 @@
package entities
type RecordingDepletion struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;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"`
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
SourceProjectFlockKandangId *uint `gorm:"column:source_project_flock_kandang_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"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"`
}
+16 -14
View File
@@ -3,18 +3,20 @@ package entities
import "time"
type RecordingEgg struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Qty int `gorm:"column:qty;not null"`
TotalQty float64 `gorm:"column:total_qty"`
TotalUsed float64 `gorm:"column:total_used"`
Weight *float64 `gorm:"column:weight"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
Qty int `gorm:"column:qty;not null"`
TotalQty float64 `gorm:"column:total_qty"`
TotalUsed float64 `gorm:"column:total_used"`
Weight *float64 `gorm:"column:weight"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
}
+59 -13
View File
@@ -19,11 +19,11 @@ const (
// AuthContext keeps authentication details captured by the middleware.
type AuthContext struct {
Token string
Verification *sso.VerificationResult
User *entity.User
Roles []sso.Role
Permissions map[string]struct{}
Token string
Verification *sso.VerificationResult
User *entity.User
Roles []sso.Role
Permissions map[string]struct{}
UserAreaIDs []uint
UserLocationIDs []uint
UserAllArea bool
@@ -36,8 +36,30 @@ type AuthContext struct {
func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
token := bearerToken(c)
if token == "" {
token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName))
tokenSource := ""
if token != "" {
tokenSource = "header"
} else {
primaryName := strings.TrimSpace(config.SSOAccessCookieName)
if primaryName != "" {
token = strings.TrimSpace(c.Cookies(primaryName))
if token != "" {
tokenSource = "cookie:" + primaryName
}
}
if token == "" {
for _, name := range config.SSOAccessCookieFallback {
name = strings.TrimSpace(name)
if name == "" || name == primaryName {
continue
}
token = strings.TrimSpace(c.Cookies(name))
if token != "" {
tokenSource = "cookie:" + name
break
}
}
}
}
if token == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
@@ -45,7 +67,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
verification, err := sso.VerifyAccessToken(token)
if err != nil {
utils.Log.WithError(err).Warn("auth: token verification failed")
if sso.IsSignatureError(err) {
logSignatureError("auth", tokenSource, token, err)
} else {
utils.Log.WithError(err).Warn("auth: token verification failed")
}
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
@@ -89,11 +115,11 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
}
ctx := &AuthContext{
Token: token,
Verification: verification,
User: user,
Roles: roles,
Permissions: permissions,
Token: token,
Verification: verification,
User: user,
Roles: roles,
Permissions: permissions,
UserAreaIDs: nil,
UserLocationIDs: nil,
UserAllArea: false,
@@ -216,6 +242,26 @@ func hasAllScopes(have, required []string) bool {
return true
}
func logSignatureError(ctxLabel, tokenSource, token string, err error) {
info := sso.ExtractTokenInfo(token)
aud := strings.Join(info.Aud, ",")
utils.Log.Errorf(
"access token verification failed: %v | ctx=%s source=%s iss=%s kid=%s aud=%s sub=%s exp=%d iat=%d nbf=%d expected_iss=%s expected_aud=%v",
err,
ctxLabel,
tokenSource,
info.Iss,
info.Kid,
aud,
info.Sub,
info.Exp,
info.Iat,
info.Nbf,
config.SSOIssuer,
config.SSOAllowedAudiences,
)
}
// RequirePermissions ensures the authenticated user possesses all specified permissions.
func RequirePermissions(perms ...string) fiber.Handler {
required := canonicalPermissions(perms)
+7 -3
View File
@@ -38,9 +38,10 @@ const (
P_ExpenseDocumentRealizations = "lti.expense.document.realization"
)
const (
P_AdjustmentGetAll = "lti.inventory.list"
P_AdjustmentCreate = "lti.inventory.create"
P_AdjustmentGetOne = "lti.inventory.detail"
P_AdjustmentGetAll = "lti.inventory.list"
P_AdjustmentCreate = "lti.inventory.create"
P_AdjustmentGetOne = "lti.inventory.detail"
P_AdjustmentDeleteOne = "lti.inventory.delete"
)
const (
P_ApprovalGetAll = "lti.approval.list"
@@ -70,6 +71,7 @@ const (
P_TransferGetAll = "lti.inventory.transfer.list"
P_TransferGetOne = "lti.inventory.transfer.detail"
P_TransferCreateOne = "lti.inventory.transfer.create"
P_TransferDeleteOne = "lti.inventory.transfer.delete"
)
const (
@@ -130,6 +132,8 @@ const (
P_KandangsUpdateOne = "lti.master.kandangs.update"
P_KandangsDeleteOne = "lti.master.kandangs.delete"
P_KandangGroups = "lti.daily_checklist.master_data.kandang"
P_LocationsGetAll = "lti.master.locations.list"
P_LocationsGetOne = "lti.master.locations.detail"
P_LocationsCreateOne = "lti.master.locations.create"
@@ -44,6 +44,7 @@ type PenjualanRealisasiResponseDTO struct {
// === Mapper Functions ===
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e)
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
@@ -51,11 +52,11 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
if projectFlockKandang != nil {
category = projectFlockKandang.ProjectFlock.Category
}
ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
ageInDay, ageInWeeks := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category)
var product *productDTO.ProductRelationDTO
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
@@ -70,8 +71,8 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
var kandang *kandangDTO.KandangRelationDTO
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil && e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang)
if projectFlockKandang != nil && projectFlockKandang.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(projectFlockKandang.Kandang)
kandang = &mapped
}
@@ -102,6 +103,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e)
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
@@ -109,11 +111,11 @@ func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
if projectFlockKandang != nil {
category = projectFlockKandang.ProjectFlock.Category
}
ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
ageInDay, _ := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category)
return SalesDTO{
Age: ageInDay,
@@ -164,6 +166,13 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua
}
}
func resolveMarketingDeliveryProjectFlockKandang(e entity.MarketingDeliveryProduct) *entity.ProjectFlockKandang {
if e.AttributedProjectFlockKandang != nil {
return e.AttributedProjectFlockKandang
}
return e.MarketingProduct.ProductWarehouse.ProjectFlockKandang
}
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0, 0
@@ -1,8 +1,6 @@
package dto
import (
"encoding/json"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
@@ -71,7 +69,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal
return dto
}
func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int, projectFlockKandangCountMap map[uint]int) OverheadListDTO {
func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int) OverheadListDTO {
overheadsByNonstockID := make(map[uint]*OverheadDTO)
latestDateByNonstockID := make(map[uint]string)
@@ -113,35 +111,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex
qty := realizations[i].Qty
totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price)
// Farm-level expense division
if realizations[i].ExpenseNonstock.Expense != nil &&
realizations[i].ExpenseNonstock.Expense.ProjectFlockId != nil {
projectFlockIDs := parseProjectFlockIDsFromJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId)
if len(projectFlockIDs) > 0 {
totalKandangInAllProjects := 0
for _, pfID := range projectFlockIDs {
if count, exists := projectFlockKandangCountMap[pfID]; exists {
totalKandangInAllProjects += count
}
}
if totalKandangInAllProjects > 0 {
if isPerKandang {
qty = qty / float64(totalKandangInAllProjects)
totalAmount = totalAmount / float64(totalKandangInAllProjects)
} else {
// Overhead ALL: divide by total kandang then multiply by this project's kandang count
perKandangAmount := totalAmount / float64(totalKandangInAllProjects)
perKandangQty := qty / float64(totalKandangInAllProjects)
qty = perKandangQty * float64(totalKandangCount)
totalAmount = perKandangAmount * float64(totalKandangCount)
}
}
}
}
overheadsByNonstockID[nonstockID].ActualQuantity += qty
overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount
@@ -191,27 +160,6 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex
}
}
func parseProjectFlockIDsFromJSON(projectFlockJSON string) []uint {
if projectFlockJSON == "" {
return []uint{}
}
var projectFlocks []uint
if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil {
return []uint{}
}
return projectFlocks
}
func countProjectFlocksInJSON(projectFlockJSON string) int {
projectFlocks := parseProjectFlockIDsFromJSON(projectFlockJSON)
if len(projectFlocks) == 0 {
return 1
}
return len(projectFlocks)
}
func getItemInfo(nonstock *entity.Nonstock) (string, string) {
if nonstock != nil && nonstock.Id != 0 {
return nonstock.Name, nonstock.Uom.Name
@@ -65,7 +65,7 @@ type SapronakCategoryRowDTO struct {
QtyOut float64 `json:"qty_out"`
QtyUsed float64 `json:"qty_used"`
Description string `json:"description"`
ProductCategory []string `json:"product_category"`
ProductCategory string `json:"product_category"`
UnitPrice float64 `json:"unit_price"`
TotalAmount float64 `json:"total_amount"`
Notes string `json:"notes"`
@@ -183,13 +183,13 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
"PULLET": 0,
}
buildFlagList := func(productID uint, fallback string) []string {
buildFlagList := func(productID uint, fallback string) string {
rawFlags := productFlags[productID]
if len(rawFlags) == 0 {
if fallback == "" {
return []string{}
return ""
}
return []string{fallback}
return fallback
}
seen := make(map[string]struct{}, len(rawFlags))
ordered := make([]string, 0, len(rawFlags))
@@ -220,7 +220,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
return li < lj
})
return ordered
return strings.Join(ordered, " ")
}
for _, group := range report.Groups {
@@ -317,6 +317,27 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
}
// For chicken categories, keep qty_used aligned with qty_in - qty_out.
// Sales are excluded; usage represents remaining after transfers.
adjustChicken := func(cat *SapronakCategoryDTO) {
if cat == nil {
return
}
for i := range cat.Rows {
row := &cat.Rows[i]
remaining := row.QtyIn - row.QtyOut
if remaining < 0 {
remaining = 0
}
row.QtyUsed = remaining
if row.UnitPrice > 0 {
row.TotalAmount = row.QtyUsed * row.UnitPrice
}
}
}
adjustChicken(result.Doc)
adjustChicken(result.Pullet)
buildTotals := func(cat *SapronakCategoryDTO, label string) {
if cat == nil {
return
@@ -345,5 +366,22 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
buildTotals(result.Doc, "TOTAL DOC")
buildTotals(result.Ovk, "TOTAL OVK")
buildTotals(result.Pakan, "TOTAL PAKAN")
// For chicken categories, enforce total qty_used = qty_in - qty_out.
adjustChickenTotal := func(cat *SapronakCategoryDTO) {
if cat == nil {
return
}
remaining := cat.Total.QtyIn - cat.Total.QtyOut
if remaining < 0 {
remaining = 0
}
cat.Total.QtyUsed = remaining
if cat.Total.AvgUnitPrice > 0 {
cat.Total.TotalAmount = cat.Total.AvgUnitPrice * remaining
}
}
adjustChickenTotal(result.Doc)
adjustChickenTotal(result.Pullet)
return result
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
package repository
import (
"context"
"fmt"
"testing"
"time"
"github.com/glebarez/sqlite"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
func TestSapronakIncomingPurchaseQueryPartsUsesAttributedPurchasesWhenProjectFlockKandangIDsProvided(t *testing.T) {
sql, args := sapronakIncomingPurchaseQueryParts(SapronakQueryParams{
WarehouseIDs: []uint{46},
ProjectFlockKandangIDs: []uint{101},
})
if sql != sapronakIncomingPurchasesScopedSQL() {
t.Fatalf("expected scoped purchase SQL, got %q", sql)
}
if len(args) != 8 {
t.Fatalf("expected 8 argument groups, got %d", len(args))
}
}
func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWarehouseFallback(t *testing.T) {
db := setupClosingRepositoryTestDB(t)
repo := NewClosingRepository(db)
ctx := context.Background()
receivedAt := time.Date(2026, 4, 1, 4, 0, 0, 0, time.UTC)
statements := []string{
`INSERT INTO warehouses (id, kandang_id) VALUES (1, NULL), (2, 59), (3, 88)`,
`INSERT INTO product_categories (id, code) VALUES (1, 'OBT'), (2, 'RAW')`,
`INSERT INTO products (id, name, product_category_id, product_price) VALUES
(10, 'MEFISTO @1 LITER', 1, 261700),
(20, 'PAKAN GROWING CRUMBLE MALINDO', 2, 15000)`,
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES
(1, 10, 'products', 'OVK'),
(2, 10, 'products', 'OBAT')`,
`INSERT INTO purchases (id, po_number, deleted_at) VALUES (1, 'PO-LTI-0005', NULL)`,
`INSERT INTO recordings (id, project_flock_kandangs_id, deleted_at) VALUES (11, 101, NULL), (12, 999, NULL)`,
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty) VALUES (21, 11, 501, 150), (22, 12, 502, 10)`,
`INSERT INTO purchase_items (id, purchase_id, product_id, warehouse_id, project_flock_kandang_id, total_qty, price, received_date) VALUES
(1, 1, 10, 1, NULL, 100, 261700, '` + receivedAt.Format(time.RFC3339) + `'),
(2, 1, 20, 1, NULL, 50, 15000, '` + receivedAt.Format(time.RFC3339) + `'),
(3, 1, 20, 2, NULL, 25, 12000, '` + receivedAt.Format(time.RFC3339) + `'),
(4, 1, 10, 3, 999, 10, 261700, '` + receivedAt.Format(time.RFC3339) + `'),
(5, 1, 20, 1, NULL, 40, 15000, '` + receivedAt.Format(time.RFC3339) + `')`,
fmt.Sprintf(`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, allocation_purpose, status) VALUES
(1, 701, '%s', 1, '%s', 21, 100, 'CONSUME', 'ACTIVE'),
(2, 702, '%s', 2, '%s', 21, 50, 'CONSUME', 'ACTIVE'),
(3, 703, '%s', 5, '%s', 22, 40, 'CONSUME', 'ACTIVE')`,
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyRecordingStock.String(),
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyRecordingStock.String(),
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyRecordingStock.String(),
),
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding schema: %v", err)
}
}
rows, err := repo.FetchSapronakIncoming(ctx, 101, 59, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 sapronak rows, got %d", len(rows))
}
byProduct := make(map[uint]SapronakIncomingRow, len(rows))
for _, row := range rows {
byProduct[row.ProductID] = row
}
if got := byProduct[10]; got.ProductID == 0 || got.Flag != "OVK" || got.Qty != 100 {
t.Fatalf("expected OVK farm purchase qty 100 for product 10, got %+v", got)
}
if got := byProduct[20]; got.ProductID == 0 || got.Flag != "PAKAN" || got.Qty != 75 {
t.Fatalf("expected PAKAN total qty 75 including farm allocated qty 50 and kandang receipt qty 25, got %+v", got)
}
}
func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY,
kandang_id INTEGER NULL
)`,
`CREATE TABLE product_categories (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL
)`,
`CREATE TABLE uoms (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)`,
`CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
product_category_id INTEGER NULL,
uom_id INTEGER NULL,
product_price NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
`CREATE TABLE flags (
id INTEGER PRIMARY KEY,
flagable_id INTEGER NOT NULL,
flagable_type TEXT NOT NULL,
name TEXT NOT NULL
)`,
`CREATE TABLE purchases (
id INTEGER PRIMARY KEY,
po_number TEXT NULL,
notes TEXT NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE purchase_items (
id INTEGER PRIMARY KEY,
purchase_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
received_date TIMESTAMP NULL
)`,
`CREATE TABLE recordings (
id INTEGER PRIMARY KEY,
project_flock_kandangs_id INTEGER NOT NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE recording_stocks (
id INTEGER PRIMARY KEY,
recording_id INTEGER NOT NULL,
product_warehouse_id INTEGER NOT NULL,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NOT NULL,
stockable_type TEXT NOT NULL,
stockable_id INTEGER NOT NULL,
usable_type TEXT NOT NULL,
usable_id INTEGER NOT NULL,
qty NUMERIC(15,3) NOT NULL DEFAULT 0,
allocation_purpose TEXT NOT NULL,
status TEXT NOT NULL
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE stock_transfers (
id INTEGER PRIMARY KEY,
from_warehouse_id INTEGER NULL,
to_warehouse_id INTEGER NULL,
transfer_date TIMESTAMP NULL,
movement_number TEXT NULL,
reason TEXT NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY,
stock_transfer_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
dest_product_warehouse_id INTEGER NULL,
source_product_warehouse_id INTEGER NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
`CREATE TABLE adjustment_stocks (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NOT NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
adj_number TEXT NULL,
created_at TIMESTAMP NULL
)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -2,7 +2,6 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
@@ -33,6 +32,14 @@ import (
"gorm.io/gorm"
)
type activeKandangMetric struct {
ProjectFlockKandangID uint
ProjectFlockID uint
KandangID uint
Category string
Metric float64
}
type ClosingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error)
GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
@@ -376,7 +383,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
var projectFlockKandangIDs []uint
if params.KandangID != nil && *params.KandangID > 0 {
projectFlockKandangIDs = []uint{*params.KandangID}
} else if params.Type == validation.SapronakTypeOutgoing {
} else {
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)
@@ -385,6 +392,11 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
}
offset := (params.Page - 1) * params.Limit
startDate, endDate, err := s.getSapronakDateRange(c.Context(), projectFlockID, params.KandangID)
if err != nil {
s.Log.Errorf("Failed to resolve sapronak date range for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve sapronak date range")
}
rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{
Type: params.Type,
WarehouseIDs: warehouseIDs,
@@ -392,6 +404,8 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
Limit: params.Limit,
Offset: offset,
Search: params.Search,
StartDate: startDate,
EndDate: endDate,
})
if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -460,7 +474,7 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
var projectFlockKandangIDs []uint
if params.KandangID != nil && *params.KandangID > 0 {
projectFlockKandangIDs = []uint{*params.KandangID}
} else if params.Type == validation.SapronakTypeOutgoing {
} else {
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)
@@ -468,11 +482,19 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
}
}
startDate, endDate, err := s.getSapronakDateRange(c.Context(), projectFlockID, params.KandangID)
if err != nil {
s.Log.Errorf("Failed to resolve sapronak date range for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve sapronak date range")
}
rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{
Type: params.Type,
WarehouseIDs: warehouseIDs,
ProjectFlockKandangIDs: projectFlockKandangIDs,
Search: params.Search,
StartDate: startDate,
EndDate: endDate,
})
if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -542,6 +564,90 @@ func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFl
return ids, nil
}
func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID uint, kandangID *uint) (*time.Time, *time.Time, error) {
db := s.Repository.DB().WithContext(ctx)
if kandangID != nil && *kandangID > 0 {
var pfk entity.ProjectFlockKandang
if err := db.Select("id, created_at, closed_at").First(&pfk, *kandangID).Error; err != nil {
return nil, nil, err
}
var minChickin *time.Time
if err := db.Table("project_chickins").
Select("MIN(chick_in_date)").
Where("project_flock_kandang_id = ?", pfk.Id).
Scan(&minChickin).Error; err != nil {
return nil, nil, err
}
start := pfk.CreatedAt
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
}
startDate := dateOnlyUTC(start)
var endDate *time.Time
if pfk.ClosedAt != nil {
d := dateOnlyUTC(*pfk.ClosedAt)
endDate = &d
}
return &startDate, endDate, nil
}
var minCreated time.Time
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MIN(created_at)").
Where("project_flock_id = ?", projectFlockID).
Scan(&minCreated).Error; err != nil {
return nil, nil, err
}
var minChickin *time.Time
if err := db.Table("project_chickins pc").
Select("MIN(pc.chick_in_date)").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id").
Where("pfk.project_flock_id = ?", projectFlockID).
Scan(&minChickin).Error; err != nil {
return nil, nil, err
}
start := minCreated
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
}
startDate := dateOnlyUTC(start)
var endDate *time.Time
var openCount int64
if err := db.Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ? AND closed_at IS NULL", projectFlockID).
Count(&openCount).Error; err != nil {
return nil, nil, err
}
if openCount == 0 {
var maxClosed *time.Time
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MAX(closed_at)").
Where("project_flock_id = ?", projectFlockID).
Scan(&maxClosed).Error; err != nil {
return nil, nil, err
}
if maxClosed != nil && !maxClosed.IsZero() {
d := dateOnlyUTC(*maxClosed)
endDate = &d
}
}
return &startDate, endDate, nil
}
func dateOnlyUTC(t time.Time) time.Time {
u := t.UTC()
return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
}
func formatQuantity(qty float64, uom string) string {
qtyStr := strconv.FormatFloat(qty, 'f', -1, 64)
if uom == "" {
@@ -616,38 +722,17 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
return nil, err
}
realizations, err = s.allocateFarmOverheadRealizations(c.Context(), projectFlockID, projectFlockKandangID, realizations)
if err != nil {
return nil, err
}
projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, err
}
totalKandangCount := len(projectFlockKandangs)
// Build kandang count map for farm expense division
projectFlockKandangCountMap := make(map[uint]int)
projectFlockKandangCountMap[projectFlockID] = totalKandangCount
involvedProjectFlocks := make(map[uint]bool)
for _, realization := range realizations {
if realization.ExpenseNonstock != nil &&
realization.ExpenseNonstock.Expense != nil &&
realization.ExpenseNonstock.Expense.ProjectFlockId != nil {
var projectFlockIDs []uint
if err := json.Unmarshal([]byte(*realization.ExpenseNonstock.Expense.ProjectFlockId), &projectFlockIDs); err == nil {
for _, pfID := range projectFlockIDs {
if pfID != projectFlockID {
involvedProjectFlocks[pfID] = true
}
}
}
}
}
for pfID := range involvedProjectFlocks {
if pfKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), pfID); err == nil {
projectFlockKandangCountMap[pfID] = len(pfKandangs)
}
}
chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, err
@@ -688,11 +773,197 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
totalActualPopulation := totalChickinQty - totalDepletion
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount, projectFlockKandangCountMap)
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount)
return &result, nil
}
type activeKandangMetricRow struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProjectFlockID uint `gorm:"column:project_flock_id"`
KandangID uint `gorm:"column:kandang_id"`
Category string `gorm:"column:category"`
ChickinQty float64 `gorm:"column:chickin_qty"`
DepletionQty float64 `gorm:"column:depletion_qty"`
EggQty float64 `gorm:"column:egg_qty"`
}
func (s closingService) getActiveKandangMetrics(ctx context.Context, locationID uint, transactionDate time.Time) ([]activeKandangMetric, error) {
db := s.Repository.DB().WithContext(ctx)
rows := []activeKandangMetricRow{}
rawSQL := `
SELECT
pfk.id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pfk.kandang_id AS kandang_id,
pf.category AS category,
COALESCE((
SELECT SUM(pc.usage_qty)
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = pfk.id
AND pc.chick_in_date::date <= ?
), 0) AS chickin_qty,
COALESCE((
SELECT SUM(rd.qty)
FROM recording_depletions rd
JOIN recordings r ON r.id = rd.recording_id
WHERE r.project_flock_kandangs_id = pfk.id
AND r.record_datetime::date <= ?
), 0) AS depletion_qty,
COALESCE((
SELECT SUM(re.qty)
FROM recording_eggs re
JOIN recordings r2 ON r2.id = re.recording_id
WHERE r2.project_flock_kandangs_id = pfk.id
AND r2.record_datetime::date <= ?
), 0) AS egg_qty
FROM project_flock_kandangs pfk
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE pf.location_id = ?
AND (pfk.closed_at IS NULL OR pfk.closed_at::date > ?)
AND EXISTS (
SELECT 1
FROM project_chickins pc2
WHERE pc2.project_flock_kandang_id = pfk.id
AND pc2.chick_in_date::date <= ?
)
`
if err := db.Raw(rawSQL, transactionDate, transactionDate, transactionDate, locationID, transactionDate, transactionDate).Scan(&rows).Error; err != nil {
return nil, err
}
result := make([]activeKandangMetric, 0, len(rows))
for _, row := range rows {
metric := 0.0
switch strings.ToLower(strings.TrimSpace(row.Category)) {
case "growing":
metric = row.ChickinQty
case "laying":
metric = row.EggQty
default:
s.Log.Warnf("Unknown project flock category for overhead allocation: %s (pfk=%d)", row.Category, row.ProjectFlockKandangID)
}
result = append(result, activeKandangMetric{
ProjectFlockKandangID: row.ProjectFlockKandangID,
ProjectFlockID: row.ProjectFlockID,
KandangID: row.KandangID,
Category: row.Category,
Metric: metric,
})
}
return result, nil
}
func round2(value float64) float64 {
return math.Round(value*100) / 100
}
func allocateFarmLevelQty(totalQty float64, metrics []activeKandangMetric) map[uint]float64 {
allocations := make(map[uint]float64, len(metrics))
if totalQty == 0 || len(metrics) == 0 {
return allocations
}
totalMetric := 0.0
var maxMetric float64
var maxMetricID uint
for _, m := range metrics {
if m.Metric <= 0 {
continue
}
totalMetric += m.Metric
if m.Metric > maxMetric || maxMetricID == 0 {
maxMetric = m.Metric
maxMetricID = m.ProjectFlockKandangID
}
}
if totalMetric == 0 {
return allocations
}
sumRounded := 0.0
for _, m := range metrics {
if m.Metric <= 0 {
continue
}
portion := totalQty * (m.Metric / totalMetric)
rounded := round2(portion)
allocations[m.ProjectFlockKandangID] = rounded
sumRounded += rounded
}
diff := totalQty - sumRounded
if maxMetricID != 0 && diff != 0 {
allocations[maxMetricID] = round2(allocations[maxMetricID] + diff)
}
return allocations
}
func (s closingService) allocateFarmOverheadRealizations(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, realizations []entity.ExpenseRealization) ([]entity.ExpenseRealization, error) {
if len(realizations) == 0 {
return realizations, nil
}
cache := make(map[string][]activeKandangMetric)
allocated := make([]entity.ExpenseRealization, 0, len(realizations))
for _, realization := range realizations {
expenseNonstock := realization.ExpenseNonstock
if expenseNonstock == nil || expenseNonstock.Expense == nil {
allocated = append(allocated, realization)
continue
}
// If already bound to a specific project flock kandang, don't re-allocate.
if expenseNonstock.ProjectFlockKandangId != nil {
allocated = append(allocated, realization)
continue
}
expense := expenseNonstock.Expense
locationID := uint(expense.LocationId)
txDate := expense.RealizationDate
cacheKey := fmt.Sprintf("%d|%s", locationID, txDate.Format("2006-01-02"))
metrics, exists := cache[cacheKey]
if !exists {
var err error
metrics, err = s.getActiveKandangMetrics(ctx, locationID, txDate)
if err != nil {
return nil, err
}
cache[cacheKey] = metrics
}
allocations := allocateFarmLevelQty(realization.Qty, metrics)
allocatedQty := 0.0
if projectFlockKandangID != nil {
allocatedQty = allocations[*projectFlockKandangID]
} else {
for _, m := range metrics {
if m.ProjectFlockID == projectFlockID {
allocatedQty += allocations[m.ProjectFlockKandangID]
}
}
allocatedQty = round2(allocatedQty)
}
adj := realization
adj.Qty = allocatedQty
if adj.Qty == 0 {
adj.Price = realization.Price
}
allocated = append(allocated, adj)
}
return allocated, nil
}
func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) {
if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
@@ -885,7 +1156,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
chickenDepletion = 0
}
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age)
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age)
if fcrActFromRecording != nil {
chickenPerformance.FcrAct = *fcrActFromRecording
}
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -123,7 +124,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
continue
}
// We no longer filter by date for closing sapronak report; pass nil pointers.
// Filter sapronak data by project flock period range.
items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag)
if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err)
@@ -379,33 +380,33 @@ func buildSapronakDetails(
}
func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) {
// For sapronak closing report we intentionally ignore date range
// and aggregate all historical transactions for the kandang/project.
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId)
// Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any).
startDate, endDate := sapronakPeriodRange(pfk)
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.Id, pfk.KandangId, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId)
incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.Id, pfk.KandangId, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id)
usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id)
chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id)
usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id)
chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id)
usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
@@ -413,15 +414,15 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
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, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId)
transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id)
salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id, startDate, endDate)
if err != nil {
return nil, nil, 0, 0, err
}
@@ -470,6 +471,7 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
// should not be counted yet. Only when category is LAYING we allow
// pullet usage to contribute to qty_used.
isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying))
hasChickin := len(pfk.Chickins) > 0
if !isLaying {
filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows))
@@ -491,11 +493,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
chickinUsageDetailsRows = filteredDetail
}
allUsageRows := append(usageRows, chickinUsageRows...)
incoming, usage := mapIncomingUsage(incomingRows, allUsageRows)
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
groupMap := make(map[string]*dto.SapronakGroupDTO)
for pid, rows := range chickinUsageDetailsRows {
if len(rows) == 0 {
continue
@@ -512,6 +509,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
transOutgoing := detailMaps.TransferOut
salesOutgoing := detailMaps.SalesOut
allUsageRows := append(usageRows, chickinUsageRows...)
incoming, usage := mapIncomingUsage(incomingRows, allUsageRows)
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
groupMap := make(map[string]*dto.SapronakGroupDTO)
transIncoming = dedupTransfers(transIncoming)
transOutgoing = dedupTransfers(transOutgoing)
@@ -775,6 +777,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if !matchesFlag(flag) {
continue
}
if hasChickin && (strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER")) {
continue
}
group := ensureGroup(flag)
for _, d := range details {
if d.Flag == "" {
@@ -794,6 +799,10 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if !matchesFlag(flag) {
continue
}
// For chicken, we don't count sales as sapronak outflow.
if strings.EqualFold(flag, "DOC") || strings.EqualFold(flag, "PULLET") || strings.EqualFold(flag, "LAYER") {
continue
}
group := ensureGroup(flag)
for _, d := range details {
if d.Flag == "" {
@@ -815,3 +824,20 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
return items, groups, totalIncoming, totalUsage, nil
}
func sapronakPeriodRange(pfk entity.ProjectFlockKandang) (*time.Time, *time.Time) {
if len(pfk.Chickins) == 0 {
start := dateOnlyUTC(pfk.CreatedAt)
return &start, pfk.ClosedAt
}
minDate := pfk.Chickins[0].ChickInDate
for _, c := range pfk.Chickins[1:] {
if c.ChickInDate.Before(minDate) {
minDate = c.ChickInDate
}
}
start := dateOnlyUTC(minDate)
return &start, pfk.ClosedAt
}
@@ -32,6 +32,44 @@ func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error)
}
sort.Strings(flagList)
productMainFlags := utils.ProductMainFlags()
productMainFlagValues := make([]string, len(productMainFlags))
for i, flag := range productMainFlags {
productMainFlagValues[i] = string(flag)
}
type productFlagOption struct {
Flag string `json:"flag"`
SubFlags []string `json:"sub_flags"`
AllowWithoutSubFlag bool `json:"allow_without_sub_flag"`
}
productOptions := utils.ProductFlagOptions()
productFlagOptions := make([]productFlagOption, 0, len(productOptions))
for _, option := range productOptions {
subFlags := make([]string, len(option.SubFlags))
for i, subFlag := range option.SubFlags {
subFlags[i] = string(subFlag)
}
productFlagOptions = append(productFlagOptions, productFlagOption{
Flag: string(option.Flag),
SubFlags: subFlags,
AllowWithoutSubFlag: option.AllowWithoutSubFlag,
})
}
productSubFlagToFlagRaw := utils.ProductSubFlagToFlag()
productSubFlagToFlag := make(map[string]string, len(productSubFlagToFlagRaw))
for subFlag, flag := range productSubFlagToFlagRaw {
productSubFlagToFlag[string(subFlag)] = string(flag)
}
legacyAliasesRaw := utils.LegacyFlagTypeAliases()
legacyAliases := make(map[string]string, len(legacyAliasesRaw))
for legacy, canonical := range legacyAliasesRaw {
legacyAliases[string(legacy)] = string(canonical)
}
type approvalStepConstant struct {
StepNumber uint16 `json:"step_number"`
StepName string `json:"step_name"`
@@ -96,9 +134,15 @@ func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error)
"BISNIS",
"INDIVIDUAL",
},
"adjustment": map[string]interface{}{
"transaction_subtypes": adjustmentSubtypesByType,
},
"approval_workflows": approvalWorkflows,
}, nil
"adjustment": map[string]interface{}{
"transaction_subtypes": adjustmentSubtypesByType,
},
"legacy_flag_aliases": legacyAliases,
"product_flag_mapping": map[string]interface{}{
"flags": productMainFlagValues,
"options": productFlagOptions,
"sub_flag_to_flag": productSubFlagToFlag,
},
"approval_workflows": approvalWorkflows,
}, nil
}
@@ -64,9 +64,11 @@ func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error {
status = *item.Status
}
var kandang *kandangDTO.KandangRelationDTO
// var kandang *kandangDTO.KandangRelationDTO
var kandang *kandangDTO.KandangGroupRelationDTO
if item.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(item.Kandang)
// mapped := kandangDTO.ToKandangRelationDTO(item.Kandang)x
mapped := kandangDTO.ToKandangGroupRelationDTO(item.Kandang)
kandang = &mapped
}
@@ -19,19 +19,19 @@ type DailyChecklistRelationDTO struct {
}
type DailyChecklistListDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Category string `json:"category"`
Date time.Time `json:"date"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TotalPhase int `json:"total_phase"`
TotalActivity int `json:"total_activity"`
Progress int `json:"progress"`
RejectReason *string `json:"reject_reason"`
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Category string `json:"category"`
Date time.Time `json:"date"`
Kandang *kandangDTO.KandangGroupRelationDTO `json:"kandang,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TotalPhase int `json:"total_phase"`
TotalActivity int `json:"total_activity"`
Progress int `json:"progress"`
RejectReason *string `json:"reject_reason"`
}
type DailyChecklistDetailDTO struct {
@@ -156,9 +156,9 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO {
status = *e.Status
}
var kandang *kandangDTO.KandangRelationDTO
var kandang *kandangDTO.KandangGroupRelationDTO
if e.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(e.Kandang)
mapped := kandangDTO.ToKandangGroupRelationDTO(e.Kandang)
kandang = &mapped
}
@@ -75,7 +75,7 @@ type DailyChecklistListItem struct {
RejectReason *string
CreatedAt time.Time
UpdatedAt time.Time
Kandang entity.Kandang
Kandang entity.KandangGroup
TotalPhase int
TotalActivity int
Progress int
@@ -142,7 +142,7 @@ func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID u
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id = ?", checklistID)
@@ -168,7 +168,7 @@ func (s dailyChecklistService) ensureKandangAccess(c *fiber.Ctx, kandangID uint)
}
db := s.Repository.DB().WithContext(c.Context()).
Table("kandangs k").
Table("kandang_groups k").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("k.id = ?", kandangID)
@@ -196,7 +196,7 @@ func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error
db := s.Repository.DB().WithContext(c.Context()).
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 kandang_groups 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)
@@ -225,7 +225,7 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id")
@@ -261,8 +261,11 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
if params.Search != "" {
re := regexp.MustCompile("[^a-zA-Z0-9]")
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))
normalizedSearch := re.ReplaceAllString(params.Search, "")
if normalizedSearch != "" {
like := "%" + normalizedSearch + "%"
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 ?)", like, like)
}
}
countDB := db.Session(&gorm.Session{})
@@ -341,9 +344,9 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
}
}
kandangMap := make(map[uint]entity.Kandang)
kandangMap := make(map[uint]entity.KandangGroup)
if len(kandangIDs) > 0 {
var kandangs []entity.Kandang
var kandangs []entity.KandangGroup
if err := s.Repository.DB().WithContext(c.Context()).
Where("id IN ?", kandangIDs).
Preload("Location").
@@ -504,24 +507,66 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
status := req.Status
category := req.Category
targetID := uint(0)
createBody := &entity.DailyChecklist{
KandangId: req.KandangId,
Date: date,
Category: category,
Status: &status,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
existing := new(entity.DailyChecklist)
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
Take(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
err = s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}, {Name: "kandang_id"}, {Name: "category"}},
DoUpdates: clause.Assignments(map[string]any{"updated_at": time.Now()}),
}).Create(createBody).Error
if err == nil {
if err := tx.Model(&entity.DailyChecklist{}).
Where("id = ?", existing.Id).
Update("updated_at", time.Now()).Error; err != nil {
return err
}
targetID = existing.Id
return nil
}
createStatus := status
var rejectedCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND status = ?", date, req.KandangId, category, "REJECTED").
Count(&rejectedCount).Error; err != nil {
return err
}
if rejectedCount > 0 {
createStatus = "DRAFT"
}
createBody := &entity.DailyChecklist{
KandangId: req.KandangId,
Date: date,
Category: category,
Status: &createStatus,
}
if err := tx.Create(createBody).Error; err != nil {
// Handle concurrent insert for active checklist with same key.
if findErr := tx.
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
Take(existing).Error; findErr == nil {
targetID = existing.Id
return nil
}
return err
}
targetID = createBody.Id
return nil
})
if err != nil {
s.Log.Errorf("Failed to upsert dailyChecklist: %+v", err)
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
return s.GetOne(c, targetID)
}
func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) {
@@ -1019,7 +1064,7 @@ func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.Summa
MAX(a.updated_at) AS last_activity`).
Joins("JOIN daily_checklist_activity_tasks t ON t.id = a.task_id").
Joins("JOIN daily_checklists d ON d.id = t.checklist_id").
Joins("JOIN kandangs k ON k.id = d.kandang_id").
Joins("JOIN kandang_groups k ON k.id = d.kandang_id").
Joins("JOIN employees e ON e.id = a.employee_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas ar ON ar.id = loc.area_id").
@@ -1092,7 +1137,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
Joins("JOIN daily_checklist_activity_tasks dcat ON dcat.id = dca.task_id").
Joins("JOIN daily_checklists dc ON dc.id = dcat.checklist_id").
Joins("JOIN employees e ON e.id = dca.employee_id").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Joins("JOIN phases p ON p.id = dcat.phase_id").
@@ -35,6 +35,7 @@ type UniformityWeeklyMetric struct {
Week int
Uniformity float64
AverageWeight float64
UniformDate time.Time
}
type StandardWeeklyMetric struct {
@@ -144,7 +145,8 @@ func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context
Table("project_flock_kandang_uniformity AS u").
Select(`u.week AS week,
COALESCE(AVG(u.uniformity), 0) AS uniformity,
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`).
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight,
MAX(u.uniform_date) AS uniform_date`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Where("u.uniform_date IS NOT NULL").
@@ -265,6 +265,7 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
}
bodyWeightDataset := make([]map[string]interface{}, 0, len(weeks))
bodyWeightDatasetIndexByWeek := make(map[int]int, len(weeks))
performanceDataset := make([]map[string]interface{}, 0, len(weeks))
fcrDataset := make([]map[string]interface{}, 0, len(weeks))
deplesiDataset := make([]map[string]interface{}, 0, len(weeks))
@@ -298,6 +299,7 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
"body_weight": roundTo(uni.AverageWeight, 2),
"std_body_weight": roundTo(std.StdBodyWeight, 2),
})
bodyWeightDatasetIndexByWeek[week] = len(bodyWeightDataset) - 1
performanceDataset = append(performanceDataset, map[string]interface{}{
"week": week,
@@ -326,6 +328,15 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
})
}
bodyWeightDataset = extendBodyWeightDatasetUntilEndDate(
bodyWeightDataset,
bodyWeightDatasetIndexByWeek,
uniformities,
uniformityMap,
standardMap,
params.PeriodEnd,
)
qualityRows, err := s.Repository.GetEggQualityWeeklyMetrics(ctx, startDate, endExclusive, filter)
if err != nil {
return nil, err
@@ -1049,6 +1060,69 @@ func (s dashboardService) avgSellingPrice(ctx context.Context, filter *validatio
return result.TotalPrice / result.TotalWeight, nil
}
func extendBodyWeightDatasetUntilEndDate(
dataset []map[string]interface{},
indexByWeek map[int]int,
uniformities []repository.UniformityWeeklyMetric,
uniformityMap map[int]repository.UniformityWeeklyMetric,
standardMap map[int]repository.StandardWeeklyMetric,
periodEnd time.Time,
) []map[string]interface{} {
latestUniformityWeek := 0
var latestUniformityDate time.Time
for _, row := range uniformities {
if row.Week <= 0 || row.UniformDate.IsZero() {
continue
}
if latestUniformityDate.IsZero() || row.UniformDate.After(latestUniformityDate) || (row.UniformDate.Equal(latestUniformityDate) && row.Week > latestUniformityWeek) {
latestUniformityDate = row.UniformDate
latestUniformityWeek = row.Week
}
}
if latestUniformityWeek <= 0 || latestUniformityDate.IsZero() || periodEnd.IsZero() || !periodEnd.After(latestUniformityDate) {
return dataset
}
additionalWeeks := int(math.Ceil(periodEnd.Sub(latestUniformityDate).Hours() / (24 * 7)))
if additionalWeeks <= 0 {
return dataset
}
lastUniformity := uniformityMap[latestUniformityWeek]
lastStandard := standardMap[latestUniformityWeek]
latestBodyWeight := roundTo(lastUniformity.AverageWeight, 2)
latestStdBodyWeight := roundTo(lastStandard.StdBodyWeight, 2)
targetWeek := latestUniformityWeek + additionalWeeks
for week := latestUniformityWeek + 1; week <= targetWeek; week++ {
row := map[string]interface{}{
"week": week,
"body_weight": latestBodyWeight,
"std_body_weight": latestStdBodyWeight,
}
if idx, ok := indexByWeek[week]; ok {
dataset[idx] = row
continue
}
dataset = append(dataset, row)
indexByWeek[week] = len(dataset) - 1
}
sort.Slice(dataset, func(i, j int) bool {
return datasetWeek(dataset[i]) < datasetWeek(dataset[j])
})
return dataset
}
func datasetWeek(row map[string]interface{}) int {
week, _ := row["week"].(int)
return week
}
func feedUsageToGrams(rows []repository.FeedUsageByUom) float64 {
total := 0.0
for _, row := range rows {
@@ -69,23 +69,19 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex
Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id").
Joins("JOIN 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 kandangs ON kandangs.id = expense_nonstocks.kandang_id").
Where("expenses.realization_date IS NOT NULL").
Where("expenses.category = ?", "BOP")
if projectFlockKandangID != nil {
db = db.Where(`(
expense_nonstocks.project_flock_kandang_id = ? OR
(expense_nonstocks.kandang_id = (SELECT kandang_id FROM project_flock_kandangs WHERE id = ?) AND
expense_nonstocks.project_flock_kandang_id IS NULL) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, *projectFlockKandangID, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID))
)`, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID))
} else {
db = db.Where(`(
project_flock_kandangs.project_flock_id = ? OR
kandangs.id IN (SELECT kandang_id FROM project_flock_kandangs WHERE project_flock_id = ?) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, projectFlockID, projectFlockID, fmt.Sprintf("[%d]", projectFlockID))
)`, projectFlockID, fmt.Sprintf("[%d]", projectFlockID))
}
err := db.Find(&realizations).Error
@@ -103,3 +103,22 @@ func (u *AdjustmentController) GetOne(c *fiber.Ctx) error {
Data: dto.ToAdjustmentDetailDTO(stockLog),
})
}
func (u *AdjustmentController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.AdjustmentService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete adjustment successfully",
})
}
@@ -5,6 +5,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto"
uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
@@ -14,6 +15,7 @@ type ProductRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
SKU string `json:"sku"`
Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"`
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
}
@@ -89,11 +91,17 @@ func ToProductRelationDTO(e *entity.Product) *ProductRelationDTO {
mapped := productCategoryDTO.ToProductCategoryRelationDTO(e.ProductCategory)
category = &mapped
}
var uom *uomDTO.UomRelationDTO
if e.Uom.Id != 0 {
mapped := uomDTO.ToUomRelationDTO(e.Uom)
uom = &mapped
}
return &ProductRelationDTO{
Id: e.Id,
Name: e.Name,
SKU: sku,
Uom: uom,
ProductCategory: category,
}
}
@@ -26,6 +26,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
userRepo := rUser.NewUserRepository(db)
productRepo := rproduct.NewProductRepository(db)
adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db)
@@ -40,6 +41,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat
fifoStockV2Service,
validate,
projectFlockKandangRepo,
projectFlockPopulationRepo,
)
userService := sUser.NewUserService(userRepo, validate)
@@ -2,12 +2,12 @@ package repositories
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -15,9 +15,19 @@ import (
type AdjustmentStockRepository interface {
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error)
GetByIDForUpdate(ctx context.Context, id uint) (*entity.AdjustmentStock, error)
FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error)
FindProductIDByProductWarehouseID(ctx context.Context, productWarehouseID uint) (uint, error)
FindRoutesByFunctionCode(ctx context.Context, productID uint, functionCode string) ([]AdjustmentRouteResolution, error)
FindOverconsumeRule(ctx context.Context, lane, flagGroupCode, functionCode string) (*bool, error)
LoadDownstreamDependencies(ctx context.Context, stockableType string, stockableIDs []uint) ([]AdjustmentDownstreamDependency, error)
FindAyamSourceProductWarehouse(ctx context.Context, warehouseID uint, projectFlockKandangID uint) (*entity.ProductWarehouse, error)
IsAyamProduct(ctx context.Context, productID uint) (bool, error)
CountActiveConsumeAllocationsByUsable(ctx context.Context, usableType string, usableID uint) (int64, error)
UpdateTotalQty(ctx context.Context, id uint, qty float64) error
UpdatePairedAdjustmentID(ctx context.Context, id uint, pairedID uint) error
DeleteStockLogsByAdjustmentID(ctx context.Context, adjustmentID uint) error
DeleteAdjustmentByID(ctx context.Context, id uint) error
ResyncProjectFlockPopulationUsage(ctx context.Context, projectFlockKandangID uint) error
FindHistory(ctx context.Context, filter AdjustmentHistoryFilter, modifier func(*gorm.DB) *gorm.DB) ([]*entity.AdjustmentStock, int64, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB
@@ -44,6 +54,13 @@ type AdjustmentHistoryFilter struct {
Limit int
}
type AdjustmentDownstreamDependency struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint64 `gorm:"column:usable_id"`
FunctionCode string `gorm:"column:function_code"`
FlagGroupCode string `gorm:"column:flag_group_code"`
}
type adjustmentStockRepositoryImpl struct {
db *gorm.DB
}
@@ -73,6 +90,17 @@ func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, mo
return &record, nil
}
func (r *adjustmentStockRepositoryImpl) GetByIDForUpdate(ctx context.Context, id uint) (*entity.AdjustmentStock, error) {
var record entity.AdjustmentStock
if err := r.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", id).
Take(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
type pfkRow struct {
KandangID uint `gorm:"column:kandang_id"`
@@ -91,6 +119,21 @@ func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx
return pfk.KandangID, nil
}
func (r *adjustmentStockRepositoryImpl) FindProductIDByProductWarehouseID(ctx context.Context, productWarehouseID uint) (uint, error) {
type productRow struct {
ProductID uint `gorm:"column:product_id"`
}
var row productRow
if err := r.db.WithContext(ctx).
Table("product_warehouses").
Select("product_id").
Where("id = ?", productWarehouseID).
Take(&row).Error; err != nil {
return 0, err
}
return row.ProductID, nil
}
func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
ctx context.Context,
productID uint,
@@ -122,37 +165,183 @@ func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
return rows, nil
}
func (r *adjustmentStockRepositoryImpl) FindOverconsumeRule(
func (r *adjustmentStockRepositoryImpl) LoadDownstreamDependencies(
ctx context.Context,
lane string,
flagGroupCode string,
functionCode string,
) (*bool, error) {
type selectedRow struct {
AllowOverconsume bool `gorm:"column:allow_overconsume"`
stockableType string,
stockableIDs []uint,
) ([]AdjustmentDownstreamDependency, error) {
if strings.TrimSpace(stockableType) == "" || len(stockableIDs) == 0 {
return nil, nil
}
var selected selectedRow
var rows []AdjustmentDownstreamDependency
err := r.db.WithContext(ctx).
Table("fifo_stock_v2_overconsume_rules").
Select("allow_overconsume").
Where("is_active = TRUE").
Where("lane = ?", lane).
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
Where("(function_code IS NULL OR function_code = ?)", functionCode).
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
Order("priority ASC, id ASC").
Limit(1).
Take(&selected).Error
Table("stock_allocations").
Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
Where("stockable_type = ?", strings.ToUpper(strings.TrimSpace(stockableType))).
Where("stockable_id IN ?", stockableIDs).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Where(
"(usable_type <> ? OR EXISTS (SELECT 1 FROM project_chickins pc WHERE pc.id = stock_allocations.usable_id AND pc.deleted_at IS NULL))",
"PROJECT_CHICKIN",
).
Group("usable_type, usable_id, function_code, flag_group_code").
Scan(&rows).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &selected.AllowOverconsume, nil
return rows, nil
}
func (r *adjustmentStockRepositoryImpl) FindAyamSourceProductWarehouse(
ctx context.Context,
warehouseID uint,
projectFlockKandangID uint,
) (*entity.ProductWarehouse, error) {
var sourcePW entity.ProductWarehouse
err := r.db.WithContext(ctx).
Model(&entity.ProductWarehouse{}).
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = product_warehouses.product_id
AND fm.flag_group_code = ?
)
`, entity.FlagableTypeProduct, "AYAM").
Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)).
Order("id ASC").
Take(&sourcePW).Error
if err != nil {
return nil, err
}
return &sourcePW, nil
}
func (r *adjustmentStockRepositoryImpl) IsAyamProduct(ctx context.Context, productID uint) (bool, error) {
if productID == 0 {
return false, nil
}
var count int64
if err := r.db.WithContext(ctx).
Table("flags f").
Joins("JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.flag_group_code = ? AND fm.is_active = TRUE", "AYAM").
Where("f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.flagable_id = ?", productID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *adjustmentStockRepositoryImpl) CountActiveConsumeAllocationsByUsable(
ctx context.Context,
usableType string,
usableID uint,
) (int64, error) {
if strings.TrimSpace(usableType) == "" || usableID == 0 {
return 0, nil
}
var count int64
err := r.db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ?", strings.ToUpper(strings.TrimSpace(usableType))).
Where("usable_id = ?", usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
func (r *adjustmentStockRepositoryImpl) UpdateTotalQty(ctx context.Context, id uint, qty float64) error {
return r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where("id = ?", id).
Update("total_qty", qty).Error
}
func (r *adjustmentStockRepositoryImpl) UpdatePairedAdjustmentID(ctx context.Context, id uint, pairedID uint) error {
return r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where("id = ?", id).
Update("paired_adjustment_id", pairedID).Error
}
func (r *adjustmentStockRepositoryImpl) DeleteStockLogsByAdjustmentID(ctx context.Context, adjustmentID uint) error {
return r.db.WithContext(ctx).
Where("loggable_type = ? AND loggable_id = ?", string(utils.StockLogTypeAdjustment), adjustmentID).
Delete(&entity.StockLog{}).Error
}
func (r *adjustmentStockRepositoryImpl) DeleteAdjustmentByID(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).
Where("id = ?", id).
Delete(&entity.AdjustmentStock{}).Error
}
func (r *adjustmentStockRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
idsSubquery := `
SELECT pfp.id
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
`
updateWithAlloc := `
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id
AND p.id IN (` + idsSubquery + `)
`
resetMissing := `
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE p.id IN (` + idsSubquery + `)
AND NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.stockable_id = p.id
)
`
db := r.db.WithContext(ctx)
if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil {
return err
}
if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil {
return err
}
return nil
}
func (r *adjustmentStockRepositoryImpl) FindHistory(
@@ -15,8 +15,9 @@ func AdjustmentRoutes(v1 fiber.Router, u user.UserService, s adjustment.Adjustme
route := v1.Group("/adjustments")
route.Use(m.Auth(u))
// Standard CRUD routes following master data pattern
route.Get("/",m.RequirePermissions(m.P_AdjustmentGetAll), ctrl.AdjustmentHistory) // Get all with pagination and filters
route.Post("/",m.RequirePermissions(m.P_AdjustmentCreate), ctrl.Adjustment) // Create adjustment
route.Get("/:id",m.RequirePermissions(m.P_AdjustmentGetOne), ctrl.GetOne)
route.Get("/", m.RequirePermissions(m.P_AdjustmentGetAll), ctrl.AdjustmentHistory) // Get all with pagination and filters
route.Post("/", m.RequirePermissions(m.P_AdjustmentCreate), ctrl.Adjustment) // Create adjustment
route.Get("/:id", m.RequirePermissions(m.P_AdjustmentGetOne), ctrl.GetOne)
route.Delete("/:id", m.RequirePermissions(m.P_AdjustmentDeleteOne), ctrl.DeleteOne)
}
@@ -5,12 +5,14 @@ import (
"errors"
"fmt"
"math"
"sort"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
common "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
@@ -21,31 +23,33 @@ import (
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type AdjustmentService interface {
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error)
}
type adjustmentService struct {
Log *logrus.Logger
Validate *validator.Validate
StockLogsRepository stockLogsRepo.StockLogRepository
WarehouseRepo warehouseRepo.WarehouseRepository
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
ProductRepo productRepo.ProductRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository
FifoStockV2Svc common.FifoStockV2Service
Log *logrus.Logger
Validate *validator.Validate
StockLogsRepository stockLogsRepo.StockLogRepository
WarehouseRepo warehouseRepo.WarehouseRepository
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
ProductRepo productRepo.ProductRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository
AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository
FifoStockV2Svc common.FifoStockV2Service
}
const (
adjustmentLaneStockable = "STOCKABLE"
adjustmentLaneUsable = "USABLE"
flagGroupAyam = "AYAM"
)
func NewAdjustmentService(
@@ -57,37 +61,37 @@ func NewAdjustmentService(
fifoStockV2Svc common.FifoStockV2Service,
validate *validator.Validate,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository,
) AdjustmentService {
return &adjustmentService{
Log: utils.Log,
Validate: validate,
StockLogsRepository: stockLogsRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProductRepo: productRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
AdjustmentStockRepository: adjustmentStockRepo,
FifoStockV2Svc: fifoStockV2Svc,
Log: utils.Log,
Validate: validate,
StockLogsRepository: stockLogsRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProductRepo: productRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
AdjustmentStockRepository: adjustmentStockRepo,
FifoStockV2Svc: fifoStockV2Svc,
}
}
func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("ProductWarehouse.Warehouse.Location").
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("StockLog.CreatedUser")
}
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) {
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
return nil, err
}
adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, s.withRelations)
adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("ProductWarehouse.Warehouse.Location").
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("StockLog.CreatedUser")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
@@ -99,6 +103,250 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentSto
return adjustmentStock, nil
}
func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error {
if id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid adjustment id")
}
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
return err
}
ctx := c.Context()
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return err
}
return s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
adjustments, err := s.collectAdjustmentsForDelete(ctx, tx, id)
if err != nil {
return err
}
for _, item := range adjustments {
if err := s.deleteSingleAdjustmentInTx(ctx, tx, item, actorID); err != nil {
return err
}
}
return nil
})
}
func (s *adjustmentService) collectAdjustmentsForDelete(ctx context.Context, tx *gorm.DB, id uint) ([]entity.AdjustmentStock, error) {
repoTx := s.AdjustmentStockRepository.WithTx(tx)
adjustment, err := repoTx.GetByIDForUpdate(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load adjustment")
}
adjustments := []entity.AdjustmentStock{*adjustment}
leftPairCode := utils.NormalizeUpper(adjustment.FunctionCode)
isDepletionCode := leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) ||
leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)
if !isDepletionCode {
return adjustments, nil
}
if adjustment.PairedAdjustmentId == nil || *adjustment.PairedAdjustmentId == 0 {
return nil, fiber.NewError(
fiber.StatusBadRequest,
"Adjustment depletion tidak memiliki pasangan valid. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.",
)
}
pair, err := repoTx.GetByIDForUpdate(ctx, *adjustment.PairedAdjustmentId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment depletion (%d) tidak ditemukan. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.", *adjustment.PairedAdjustmentId),
)
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load paired adjustment")
}
rightPairCode := utils.NormalizeUpper(pair.FunctionCode)
isPairDepletionCode := rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) ||
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)
if !isPairDepletionCode {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment %d bukan depletion pair yang valid", pair.Id),
)
}
if pair.PairedAdjustmentId == nil || *pair.PairedAdjustmentId != adjustment.Id {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment depletion tidak konsisten (%d <-> %d). Perbaiki pairing terlebih dahulu.", adjustment.Id, pair.Id),
)
}
isValidPair := (leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) &&
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)) ||
(leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) &&
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn))
if !isValidPair {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan function_code depletion tidak valid (%s <-> %s)", adjustment.FunctionCode, pair.FunctionCode),
)
}
adjustments = append(adjustments, *pair)
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].Id < adjustments[j].Id
})
return adjustments, nil
}
func (s *adjustmentService) deleteSingleAdjustmentInTx(
ctx context.Context,
tx *gorm.DB,
adjustment entity.AdjustmentStock,
actorID uint,
) error {
repoTx := s.AdjustmentStockRepository.WithTx(tx)
productID, err := repoTx.FindProductIDByProductWarehouseID(ctx, adjustment.ProductWarehouseId)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load product warehouse context")
}
routeMeta, err := s.resolveRouteByFunctionCode(ctx, productID, adjustment.FunctionCode)
if err != nil {
return err
}
isAyamProduct, err := repoTx.IsAyamProduct(ctx, productID)
if err != nil {
s.Log.Errorf("Failed to resolve AYAM flag for product %d: %+v", productID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product flag")
}
stockLogRepoTx := stockLogsRepo.NewStockLogRepository(tx)
notes := fmt.Sprintf("ADJUSTMENT DELETE#%s", utils.NormalizeTrim(adjustment.AdjNumber))
switch routeMeta.Lane {
case adjustmentLaneStockable:
deps, allowPending, err := s.resolveAdjustmentDependenciesAndPolicy(
ctx,
tx,
fifo.StockableKeyAdjustmentIn.String(),
[]uint{adjustment.Id},
)
if err != nil {
return err
}
if len(deps) > 0 && isAyamProduct {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Adjustment tidak dapat dihapus karena produk AYAM sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.",
formatAdjustmentDependencySummary(deps),
),
)
}
if len(deps) > 0 && !allowPending {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Adjustment tidak dapat dihapus karena stok adjustment sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.",
formatAdjustmentDependencySummary(deps),
),
)
}
oldQty := adjustment.TotalQty
if oldQty > 0 {
if err := repoTx.UpdateTotalQty(ctx, adjustment.Id, 0); err != nil {
return err
}
asOf := adjustment.CreatedAt
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
FlagGroupCode: routeMeta.FlagGroupCode,
ProductWarehouseID: adjustment.ProductWarehouseId,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err))
}
if err := s.createAdjustmentStockLog(
ctx,
stockLogRepoTx,
adjustment.Id,
adjustment.ProductWarehouseId,
notes,
actorID,
0,
oldQty,
); err != nil {
return err
}
}
case adjustmentLaneUsable:
activeBeforeRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations before rollback")
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, common.FifoStockV2RollbackRequest{
ProductWarehouseID: adjustment.ProductWarehouseId,
Usable: common.FifoStockV2Ref{
ID: adjustment.Id,
LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(),
},
Reason: notes,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to rollback FIFO v2 adjustment: %v", err))
}
activeAfterRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations after rollback")
}
if activeAfterRollback > 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Adjustment tidak dapat dihapus karena masih ada alokasi aktif ADJUSTMENT_OUT=%d (sebelum rollback=%d, sesudah rollback=%d).",
adjustment.Id,
activeBeforeRollback,
activeAfterRollback,
),
)
}
releasedQty := 0.0
if rollbackRes != nil {
releasedQty = rollbackRes.ReleasedQty
}
if releasedQty > 0 {
if err := s.createAdjustmentStockLog(
ctx,
stockLogRepoTx,
adjustment.Id,
adjustment.ProductWarehouseId,
notes,
actorID,
releasedQty,
0,
); err != nil {
return err
}
}
default:
return fiber.NewError(fiber.StatusBadRequest, "Unsupported adjustment lane")
}
if err := repoTx.DeleteStockLogsByAdjustmentID(ctx, adjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment stock logs")
}
if err := repoTx.DeleteAdjustmentByID(ctx, adjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment")
}
return nil
}
func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
@@ -117,12 +365,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero")
}
functionCode := strings.ToUpper(strings.TrimSpace(req.TransactionSubtype))
functionCode := utils.NormalizeUpper(req.TransactionSubtype)
if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(req.TransactionSubType))
functionCode = utils.NormalizeUpper(req.TransactionSubType)
}
if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(req.FunctionCode))
functionCode = utils.NormalizeUpper(req.FunctionCode)
}
if functionCode == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required")
@@ -139,9 +387,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, err
}
note := strings.TrimSpace(req.Notes)
note := utils.NormalizeTrim(req.Notes)
if note == "" {
note = strings.TrimSpace(req.Note)
note = utils.NormalizeTrim(req.Note)
}
grandTotal := math.Round((qty*req.Price)*1000) / 1000
@@ -223,8 +471,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
sourcePW, err := s.resolveAyamSourceProductWarehouse(ctx, tx, warehouseID, *projectFlockKandangID)
sourcePW, err := adjustmentStockRepoTX.FindAyamSourceProductWarehouse(ctx, warehouseID, *projectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan")
}
return err
}
if err := common.EnsureProjectFlockNotClosedForProductWarehouses(
@@ -280,6 +531,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err := adjustmentStockRepoTX.CreateOne(ctx, destinationAdjustment, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion destination adjustment stock record")
}
if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, sourceAdjustment.Id, destinationAdjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion source adjustment pair")
}
if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, destinationAdjustment.Id, sourceAdjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion destination adjustment pair")
}
sourceAdjustment.PairedAdjustmentId = &destinationAdjustment.Id
destinationAdjustment.PairedAdjustmentId = &sourceAdjustment.Id
sourceAsOf := sourceAdjustment.CreatedAt
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
@@ -309,6 +568,22 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh depletion destination adjustment stock")
}
consumedPopulationQty := refreshedSource.UsageQty + refreshedSource.PendingQty
if consumedPopulationQty > 0 {
if err := s.allocatePopulationForDepletionAdjustment(
ctx,
tx,
*projectFlockKandangID,
sourcePW.Id,
refreshedSource.Id,
consumedPopulationQty,
); err != nil {
return err
}
if err := adjustmentStockRepoTX.ResyncProjectFlockPopulationUsage(ctx, *projectFlockKandangID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to resync project flock population usage")
}
}
if err := s.createAdjustmentStockLog(
ctx,
@@ -481,29 +756,80 @@ func (s *adjustmentService) resolveRouteByFunctionCode(
}
}
func (s *adjustmentService) resolveOverconsumePolicy(
func (s *adjustmentService) resolveAdjustmentDependenciesAndPolicy(
ctx context.Context,
route *adjustmentStockRepo.AdjustmentRouteResolution,
) (bool, error) {
if route == nil {
return false, fmt.Errorf("route is required")
}
defaultValue := route.AllowPendingDefault
selected, err := s.AdjustmentStockRepository.FindOverconsumeRule(
ctx,
route.Lane,
route.FlagGroupCode,
route.FunctionCode,
)
tx *gorm.DB,
stockableType string,
stockableIDs []uint,
) ([]adjustmentStockRepo.AdjustmentDownstreamDependency, bool, error) {
deps, err := s.AdjustmentStockRepository.WithTx(tx).LoadDownstreamDependencies(ctx, stockableType, stockableIDs)
if err != nil {
return false, err
s.Log.Errorf("Failed to load downstream adjustment dependencies: %+v", err)
return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate downstream adjustment dependencies")
}
if selected == nil {
return defaultValue, nil
if len(deps) == 0 {
return nil, true, nil
}
return *selected, nil
allowPending := true
for _, dep := range deps {
policy, policyErr := common.ResolveFifoPendingPolicy(ctx, tx, common.FifoPendingPolicyInput{
Lane: adjustmentLaneUsable,
FlagGroupCode: dep.FlagGroupCode,
FunctionCode: dep.FunctionCode,
LegacyTypeKey: dep.UsableType,
})
if policyErr != nil {
s.Log.Errorf("Failed to resolve FIFO pending policy for adjustment dependency: %+v", policyErr)
return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to read FIFO v2 configuration")
}
if !policy.Found || !policy.AllowPending {
allowPending = false
break
}
}
return deps, allowPending, nil
}
func formatAdjustmentDependencySummary(rows []adjustmentStockRepo.AdjustmentDownstreamDependency) string {
if len(rows) == 0 {
return "-"
}
grouped := make(map[string]map[uint64]struct{})
for _, row := range rows {
label := utils.NormalizeUpper(row.UsableType)
if label == "" {
label = "UNKNOWN"
}
if _, ok := grouped[label]; !ok {
grouped[label] = make(map[uint64]struct{})
}
grouped[label][row.UsableID] = struct{}{}
}
labels := make([]string, 0, len(grouped))
for label := range grouped {
labels = append(labels, label)
}
sort.Strings(labels)
parts := make([]string, 0, len(labels))
for _, label := range labels {
ids := make([]uint64, 0, len(grouped[label]))
for id := range grouped[label] {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
idParts := make([]string, 0, len(ids))
for _, id := range ids {
idParts = append(idParts, fmt.Sprintf("%d", id))
}
parts = append(parts, fmt.Sprintf("%s=%s", label, strings.Join(idParts, "|")))
}
return strings.Join(parts, ", ")
}
func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
@@ -532,46 +858,6 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context,
return uint(projectFlockKandang.Id), nil
}
func (s *adjustmentService) resolveAyamSourceProductWarehouse(
ctx context.Context,
tx *gorm.DB,
warehouseID uint,
projectFlockKandangID uint,
) (*entity.ProductWarehouse, error) {
if tx == nil {
return nil, fmt.Errorf("transaction is required")
}
if projectFlockKandangID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id tidak valid untuk depletion conversion")
}
var sourcePW entity.ProductWarehouse
err := tx.WithContext(ctx).
Model(&entity.ProductWarehouse{}).
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = product_warehouses.product_id
AND fm.flag_group_code = ?
)
`, entity.FlagableTypeProduct, flagGroupAyam).
Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)).
Order("id ASC").
Take(&sourcePW).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan")
}
return nil, err
}
return &sourcePW, nil
}
func (s *adjustmentService) createAdjustmentStockLog(
ctx context.Context,
stockLogRepo stockLogsRepo.StockLogRepository,
@@ -614,6 +900,47 @@ func (s *adjustmentService) createAdjustmentStockLog(
return stockLogRepo.CreateOne(ctx, newLog, nil)
}
func (s *adjustmentService) allocatePopulationForDepletionAdjustment(
ctx context.Context,
tx *gorm.DB,
projectFlockKandangID uint,
sourceProductWarehouseID uint,
adjustmentID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if projectFlockKandangID == 0 || sourceProductWarehouseID == 0 || adjustmentID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid depletion adjustment population context")
}
if s.ProjectFlockPopulationRepo == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not available")
}
popRepoTx := s.ProjectFlockPopulationRepo.WithTx(tx)
populations, err := popRepoTx.GetByProjectFlockKandangIDAndProductWarehouseID(ctx, projectFlockKandangID, sourceProductWarehouseID)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk depletion adjustment")
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
sourceProductWarehouseID,
fifo.UsableKeyAdjustmentOut.String(),
adjustmentID,
consumeQty,
)
}
func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) {
if err := s.Validate.Struct(query); err != nil {
return nil, 0, err
@@ -656,11 +983,11 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
}
}
functionCode := strings.ToUpper(strings.TrimSpace(query.TransactionSubtype))
functionCode := utils.NormalizeUpper(query.TransactionSubtype)
if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(query.FunctionCode))
functionCode = utils.NormalizeUpper(query.FunctionCode)
}
transactionType := strings.ToUpper(strings.TrimSpace(query.TransactionType))
transactionType := utils.NormalizeUpper(query.TransactionType)
adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory(
c.Context(),
@@ -3,6 +3,7 @@ package controller
import (
"math"
"strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services"
@@ -27,11 +28,15 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
ProductId: uint(c.QueryInt("product_id", 0)),
WarehouseId: uint(c.QueryInt("warehouse_id", 0)),
LocationId: uint(c.QueryInt("location_id", 0)),
Flags: c.Query("flags", ""),
KandangId: uint(c.QueryInt("kandang_id", 0)),
AvailableOnly: parseBoolQuery(c.Query("available_only", "")),
TransferContext: c.Query(utils.TransferContextKey, ""),
StockMode: c.Query("stock_mode", ""),
Type: c.Query("type", ""),
}
@@ -59,6 +64,15 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
})
}
func parseBoolQuery(raw string) bool {
switch strings.TrimSpace(strings.ToLower(raw)) {
case "1", "true", "yes", "y":
return true
default:
return false
}
}
func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")

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