Compare commits

...

180 Commits

Author SHA1 Message Date
giovanni be440af1c2 add api update periode flock/project flock kandang 2026-04-23 21:56:17 +07:00
Adnan Zahir e6010fe47e Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
Cmd/consolidate and repoint stocks

See merge request mbugroup/lti-api!446
2026-04-23 21:32:59 +07:00
Adnan Zahir cdcc268d89 : 2026-04-23 21:32:20 +07:00
Adnan Zahir 891da70efb cmd: add commands to fix misplaced stocks and move leftover kandang stocks to farm-level warehouse 2026-04-23 21:09:04 +07:00
Giovanni Gabriel Septriadi e45ebca5a4 Merge branch 'feat/excel-po-mrk' into 'development'
[FIX][BE]: adjust validation create daily checklist empty kandang

See merge request mbugroup/lti-api!445
2026-04-23 07:26:22 +00:00
giovanni eacc460f67 adjust validation create daily checklist empty kandang 2026-04-23 14:24:13 +07:00
Giovanni Gabriel Septriadi d2ab1c7ea5 Merge branch 'feat/excel-po-mrk' into 'development'
[FIX][BE]: add kolom lokasi to export

See merge request mbugroup/lti-api!444
2026-04-23 06:50:42 +00:00
giovanni 151edf578e add kolom lokasi to export 2026-04-23 13:49:51 +07:00
Adnan Zahir e065e1fb25 Merge branch 'codex/filter-improvement' into 'development'
feat: filter improvement

See merge request mbugroup/lti-api!442
2026-04-23 00:19:08 +07:00
Adnan Zahir e24e2ff123 feat: filter improvement 2026-04-23 00:17:24 +07:00
Giovanni Gabriel Septriadi 266f683db1 Merge branch 'feat/excel-po-mrk' into 'development'
[FEAT][BE]: add export excel all expenses

See merge request mbugroup/lti-api!441
2026-04-22 16:30:16 +00:00
giovanni c744043321 add export excel all expenses 2026-04-22 23:29:05 +07:00
Giovanni Gabriel Septriadi 4673c7ad33 Merge branch 'feat/excel-po-mrk' into 'development'
[FEAT][BE]: add export excel from api Expense

See merge request mbugroup/lti-api!440
2026-04-22 15:51:19 +00:00
giovanni 3e99caf3a7 add export excel from api 2026-04-22 22:50:20 +07:00
Giovanni Gabriel Septriadi a15fd1b174 Merge branch 'fix/sapronak-cal' into 'development'
[FIX][BE]: fix perhitunga sapronak

See merge request mbugroup/lti-api!439
2026-04-22 12:36:16 +00:00
Giovanni Gabriel Septriadi 831d72cb86 Merge branch 'feat/excel-po-mrk' into 'development'
[FEAT][BE]: add export po and marketing

See merge request mbugroup/lti-api!438
2026-04-22 12:24:48 +00:00
giovanni ff630a1ed0 add export po and marketing 2026-04-22 19:22:29 +07:00
Giovanni Gabriel Septriadi 7d223c81ba Merge branch 'fix/dc-softdelete' into 'development'
[FEAT][BE]: adjust softdelete daily checklist; add empty kandang

See merge request mbugroup/lti-api!437
2026-04-22 09:26:31 +00:00
giovanni 91d51bf1b8 adjust softdelete daily checklist; add empty kandang 2026-04-22 16:24:31 +07:00
Giovanni Gabriel Septriadi 2a141a96d1 Merge branch 'codex/recording' into 'development'
Codex/recording

See merge request mbugroup/lti-api!435
2026-04-22 05:58:42 +00:00
giovanni f51fa0a16c adjust repo hpp v2 2026-04-22 12:57:41 +07:00
Adnan Zahir 9b9f5e257e Merge branch 'codex/bulk-approve-marketings-expenses' into 'development'
fix: DTO mismatch marketings

See merge request mbugroup/lti-api!434
2026-04-22 11:49:44 +07:00
Adnan Zahir adabd43f38 fix: DTO mismatch marketings 2026-04-22 11:41:27 +07:00
Adnan Zahir 640b6b382b Merge branch 'codex/bulk-approve-marketings-expenses' into 'development'
fix: DTO adjustment for bulk approve

See merge request mbugroup/lti-api!433
2026-04-22 10:11:56 +07:00
giovanni ca6e9ef0d2 adjust name migration 2026-04-22 10:11:10 +07:00
Adnan Zahir c8ea370e4b fix: DTO adjustment for bulk approve 2026-04-22 09:58:54 +07:00
giovanni fec7bb5825 adjust 2026-04-22 01:44:37 +07:00
giovanni 091f706276 init adjustment recording 2026-04-21 22:43:18 +07:00
Adnan Zahir 5594c27108 Merge branch 'codex/export-progress' into 'development'
fix: internal server error because of date parsing

See merge request mbugroup/lti-api!432
2026-04-21 21:43:25 +07:00
Adnan Zahir e91c45ee50 fix: internal server error because of date parsing 2026-04-21 21:41:54 +07:00
Adnan Zahir 5b2766676b Merge branch 'codex/export-progress' into 'development'
feat: export input progress report for expenses, marketings, purchases, and recordings

See merge request mbugroup/lti-api!431
2026-04-21 21:24:58 +07:00
Adnan Zahir 5e7c51e9c2 feat: export input progress report for expenses, marketings, purchases, and recordings 2026-04-21 21:24:19 +07:00
Adnan Zahir a98a709766 Merge branch 'codex/bulk-approve-marketings-expenses' into 'development'
feat: bulk approve endpoint for marketings and expenses

See merge request mbugroup/lti-api!430
2026-04-21 20:08:43 +07:00
Adnan Zahir 0d04397bd5 feat: bulk approve endpoint for marketings and expenses 2026-04-21 20:06:37 +07:00
giovanni ded8be198a fix perhitunga sapronak 2026-04-21 19:30:03 +07:00
Giovanni Gabriel Septriadi 1e34a0e7b2 Merge branch 'fix/bulk' into 'development'
[FIX][BE]: adjust bulk update daily checklist

See merge request mbugroup/lti-api!429
2026-04-21 08:57:31 +00:00
giovanni c5bb0ef577 adjust bulk update daily checklist 2026-04-21 13:47:20 +07:00
Adnan Zahir 5b2f66c0c7 Merge branch 'feat/bulk' into 'development'
[FEAT][BE]: add api bulk update status daily checklist; change hpp real to estimate

See merge request mbugroup/lti-api!428
2026-04-21 11:37:57 +07:00
giovanni 916f1980e9 add api bulk update status daily checklist; change hpp real to estimate 2026-04-20 16:08:43 +07:00
Adnan Zahir 5355fe0729 Merge branch 'fix/record' into 'development'
fix

See merge request mbugroup/lti-api!426
2026-04-20 10:11:12 +07:00
giovanni e679193f18 fix 2026-04-20 10:09:40 +07:00
Adnan Zahir 36a740d330 Merge branch 'codex/depresiasi' into 'development'
add command normalize data seed standar and price adjustment stocks

See merge request mbugroup/lti-api!424
2026-04-20 00:05:18 +07:00
giovanni f98c73f569 add command normalize data seed standar and price adjustment stocks 2026-04-20 00:03:59 +07:00
Adnan Zahir 75d42354e9 Merge branch 'codex/depresiasi' into 'development'
Codex/depresiasi

See merge request mbugroup/lti-api!423
2026-04-19 21:29:26 +07:00
giovanni 30adbb6b8a fix do 2026-04-19 21:24:29 +07:00
giovanni 45bed3b765 add adjust migration 2026-04-19 21:13:48 +07:00
giovanni 04aad18a4c adjust common hpp v2 2026-04-19 17:27:42 +07:00
Adnan Zahir 69d6fc165a feat: manual pullet cost 2026-04-19 15:10:53 +07:00
Adnan Zahir a2ae139fae feat: doc direct purchase cost 2026-04-19 14:52:01 +07:00
Adnan Zahir 58fbceea24 feat: reimplement with plan hppv2 flow and logics 2026-04-19 14:06:42 +07:00
giovanni 187e497f97 add common service hpp v2; adjust query marketing without stock allocation 2026-04-18 20:08:42 +07:00
giovanni fcde3b0a36 init depresiasi 2026-04-17 21:26:56 +07:00
Adnan Zahir a54c6184a2 Merge branch 'fix/prod-recording' into 'development'
[FIX][BE]: adjust validation limit recording

See merge request mbugroup/lti-api!421
2026-04-17 14:44:23 +07:00
giovanni f808b5cf79 adjust validation limit recording 2026-04-17 14:36:04 +07:00
Adnan Zahir 3d87e85d1e Merge branch 'fix/purchase' into 'development'
[FIX][BE]: fix value null to string vehicle_number field

See merge request mbugroup/lti-api!420
2026-04-15 20:30:50 +07:00
giovanni ef71093b99 fix value null to string vehicle_number field 2026-04-15 11:22:02 +07:00
Adnan Zahir 1c19fc058c Merge branch 'feat/open-api-v1' into 'development'
Feat/open api v1

See merge request mbugroup/lti-api!419
2026-04-14 16:54:16 +07:00
giovanni 6a012b75aa adjust collection; adjust migration 2026-04-14 16:52:50 +07:00
giovanni 9b0335cac8 Merge branch 'fix/delivery-order' into feat/open-api-v1 2026-04-14 15:30:32 +07:00
Adnan Zahir 1c70dccc82 Merge branch 'fix/dashboard' into 'development'
[FIX][BE]: fix get dashboard

See merge request mbugroup/lti-api!418
2026-04-14 15:25:08 +07:00
giovanni 94a7191365 fix get dashboard 2026-04-14 15:23:08 +07:00
Adnan Zahir 1ab16cfe06 feat: open API v1 and postman collection 2026-04-14 15:14:31 +07:00
Adnan Zahir d9610537de Merge branch 'fix/delivery-order' into 'development'
[FIX][BE]: adjust edit delivery order; add migration for delivery order; adjust response get marketing

See merge request mbugroup/lti-api!417
2026-04-14 15:08:32 +07:00
giovanni cd549de578 adjust edit delivery order; add migration for delivery order; adjust response get marketing 2026-04-14 14:48:56 +07:00
Adnan Zahir 7ca7d0841b Merge branch 'codex/dashboard-without-uniformity' into 'development'
Codex/dashboard without uniformity

See merge request mbugroup/lti-api!415
2026-04-14 14:31:56 +07:00
Adnan Zahir 82794d3d9b Merge branch 'development' into 'codex/dashboard-without-uniformity'
# Conflicts:
#   internal/modules/dashboards/repositories/dashboard_stats.repository.go
#   internal/modules/dashboards/services/dashboard.service.go
#   internal/modules/production/uniformities/services/uniformity.service.go
2026-04-14 14:31:18 +07:00
Adnan Zahir ca698ff2ae codex/fix: uniformity week calculation 2026-04-14 13:09:47 +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 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 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
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
Adnan Zahir 7638c183f5 codex/fix: dashboard independent recording values without uniformity 2026-04-08 15:13:31 +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 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
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
ragilap 3a8cc47fa0 Fix transfer to laying delete and fix chikin delete with response recording 2026-03-09 13:10:06 +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
kris ec17633b84 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!356
2026-03-04 17:57:51 +00:00
kris b2f235dcde Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!354
2026-03-04 17:45:35 +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 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
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
253 changed files with 58415 additions and 2133 deletions
+1
View File
@@ -30,3 +30,4 @@ coverage/
.idea/ .idea/
*.swp *.swp
.DS_Store .DS_Store
.gemini/
@@ -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
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
db := database.Connect(config.DBHost, config.DBName)
service := apikeys.NewService(db)
ctx := context.Background()
switch os.Args[1] {
case "create":
fs := flag.NewFlagSet("create", flag.ExitOnError)
name := fs.String("name", "dashboard-read-api", "integration client name")
environment := fs.String("env", config.AppEnv, "environment label")
permissions := fs.String("permissions", "", "comma separated permission codes")
allArea := fs.Bool("all-area", true, "grant all areas")
areaIDs := fs.String("area-ids", "", "comma separated area ids")
allLocation := fs.Bool("all-location", true, "grant all locations")
locationIDs := fs.String("location-ids", "", "comma separated location ids")
fs.Parse(os.Args[2:])
permissionCodes := apikeys.DefaultDashboardPermissions()
if strings.TrimSpace(*permissions) != "" {
permissionCodes = splitCSV(*permissions)
}
issued, err := service.Create(ctx, apikeys.CreateInput{
Name: *name,
Environment: *environment,
PermissionCodes: permissionCodes,
AllArea: *allArea,
AreaIDs: parseUintCSV(*areaIDs),
AllLocation: *allLocation,
LocationIDs: parseUintCSV(*locationIDs),
})
if err != nil {
panic(err)
}
fmt.Printf("name: %s\n", issued.Record.Name)
fmt.Printf("environment: %s\n", issued.Record.Environment)
fmt.Printf("prefix: %s\n", issued.Record.KeyPrefix)
fmt.Printf("status: %s\n", issued.Record.Status)
fmt.Printf("api_key: %s\n", issued.Key)
case "list":
fs := flag.NewFlagSet("list", flag.ExitOnError)
environment := fs.String("env", "", "filter by environment")
fs.Parse(os.Args[2:])
records, err := service.List(ctx, *environment)
if err != nil {
panic(err)
}
for _, record := range records {
fmt.Printf("%s\t%s\t%s\t%s\tareas=%t\tlocations=%t\n",
record.Environment,
record.KeyPrefix,
record.Status,
record.Name,
record.AllArea,
record.AllLocation,
)
}
case "revoke":
fs := flag.NewFlagSet("revoke", flag.ExitOnError)
environment := fs.String("env", config.AppEnv, "environment label")
prefix := fs.String("prefix", "", "key prefix to revoke")
fs.Parse(os.Args[2:])
if err := service.Revoke(ctx, *environment, *prefix); err != nil {
panic(err)
}
fmt.Printf("revoked %s/%s\n", *environment, *prefix)
default:
usage()
os.Exit(1)
}
}
func usage() {
fmt.Println("usage:")
fmt.Println(" go run ./cmd/api-key create [flags]")
fmt.Println(" go run ./cmd/api-key list [flags]")
fmt.Println(" go run ./cmd/api-key revoke -env <environment> -prefix <prefix>")
}
func splitCSV(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func parseUintCSV(raw string) []uint {
parts := splitCSV(raw)
if len(parts) == 0 {
return nil
}
values := make([]uint, 0, len(parts))
for _, part := range parts {
var value uint
if _, err := fmt.Sscanf(part, "%d", &value); err == nil && value > 0 {
values = append(values, value)
}
}
return values
}
+5
View File
@@ -9,12 +9,14 @@ import (
"syscall" "syscall"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/cache" "gitlab.com/mbugroup/lti-api.git/internal/cache"
"gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database" "gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
"gitlab.com/mbugroup/lti-api.git/internal/route" "gitlab.com/mbugroup/lti-api.git/internal/route"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -131,6 +133,7 @@ func setupDatabase() *gorm.DB {
} }
func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) { func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
middleware.SetAPIKeyAuthenticator(apikeys.NewService(db))
// route.Routes(app, db) // route.Routes(app, db)
// app.Use(utils.NotFoundHandler) // app.Use(utils.NotFoundHandler)
@@ -169,6 +172,8 @@ func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
return c.Status(status).JSON(body) return c.Status(status).JSON(body)
}) })
readAPIRoutes := app.Group("/api")
readapi.RegisterRoutes(readAPIRoutes)
route.Routes(app, db) route.Routes(app, db)
app.Use(utils.NotFoundHandler) app.Use(utils.NotFoundHandler)
} }
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"text/tabwriter"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
const (
outputModeTable = "table"
outputModeJSON = "json"
reportUsage = "usage"
reportWarehouses = "warehouses"
)
type options struct {
Output string
Report string
AreaName string
KandangLocationName string
WrongWarehouseName string
CorrectWarehouseName string
UsableType string
DBSSLMode string
}
type usageRow struct {
UsableType string `gorm:"column:usable_type" json:"usable_type"`
UsableID uint `gorm:"column:usable_id" json:"usable_id"`
AreaName string `gorm:"column:area_name" json:"area_name"`
LokasiName string `gorm:"column:lokasi_name" json:"lokasi_name"`
KandangName string `gorm:"column:kandang_name" json:"kandang_name"`
WrongWarehouseID uint `gorm:"column:wrong_warehouse_id" json:"wrong_warehouse_id"`
WrongWarehouseName string `gorm:"column:wrong_warehouse_name" json:"wrong_warehouse_name"`
CorrectWarehouseID uint `gorm:"column:correct_warehouse_id" json:"correct_warehouse_id"`
CorrectWarehouseName string `gorm:"column:correct_warehouse_name" json:"correct_warehouse_name"`
ProductNames string `gorm:"column:product_names" json:"product_names"`
SourcePurchaseNumbers string `gorm:"column:source_purchase_numbers" json:"source_purchase_numbers"`
SourcePurchaseItemIDs string `gorm:"column:source_purchase_item_ids" json:"source_purchase_item_ids"`
QtyFromWrongStock float64 `gorm:"column:qty_from_wrong_stock" json:"qty_from_wrong_stock"`
RecordingID *uint `gorm:"column:recording_id" json:"recording_id,omitempty"`
RecordingDate *string `gorm:"column:recording_date" json:"recording_date,omitempty"`
SoNumber *string `gorm:"column:so_number" json:"so_number,omitempty"`
SoDate *string `gorm:"column:so_date" json:"so_date,omitempty"`
}
type warehouseMismatchRow struct {
AreaName string `gorm:"column:area_name" json:"area_name"`
WrongLocationName string `gorm:"column:wrong_location_name" json:"wrong_location_name"`
KandangLocationName string `gorm:"column:kandang_location_name" json:"kandang_location_name"`
KandangID uint `gorm:"column:kandang_id" json:"kandang_id"`
KandangName string `gorm:"column:kandang_name" json:"kandang_name"`
WrongWarehouseID uint `gorm:"column:wrong_warehouse_id" json:"wrong_warehouse_id"`
WrongWarehouseName string `gorm:"column:wrong_warehouse_name" json:"wrong_warehouse_name"`
WrongWarehouseType string `gorm:"column:wrong_warehouse_type" json:"wrong_warehouse_type"`
CorrectWarehouseID uint `gorm:"column:correct_warehouse_id" json:"correct_warehouse_id"`
CorrectWarehouseName string `gorm:"column:correct_warehouse_name" json:"correct_warehouse_name"`
}
type summary struct {
Rows int `json:"rows"`
TotalQty float64 `json:"total_qty,omitempty"`
}
func main() {
opts, err := parseFlags()
if err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
if opts.DBSSLMode != "" {
config.DBSSLMode = opts.DBSSLMode
}
db := database.Connect(config.DBHost, config.DBName)
switch opts.Report {
case reportUsage:
rows, err := loadUsageRows(ctx, db, opts)
if err != nil {
log.Fatalf("failed loading usage rows: %v", err)
}
renderUsageReport(opts.Output, rows)
case reportWarehouses:
rows, err := loadWarehouseMismatchRows(ctx, db, opts)
if err != nil {
log.Fatalf("failed loading warehouse mismatch rows: %v", err)
}
renderWarehouseReport(opts.Output, rows)
default:
log.Fatalf("unsupported --report=%s", opts.Report)
}
}
func parseFlags() (*options, error) {
var opts options
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
flag.StringVar(&opts.Report, "report", reportUsage, "Report type: usage or warehouses")
flag.StringVar(&opts.AreaName, "area-name", "", "Optional exact area name filter")
flag.StringVar(&opts.KandangLocationName, "kandang-location-name", "", "Optional exact canonical kandang location filter")
flag.StringVar(&opts.WrongWarehouseName, "wrong-warehouse-name", "", "Optional exact wrong warehouse name filter")
flag.StringVar(&opts.CorrectWarehouseName, "correct-warehouse-name", "", "Optional exact correct warehouse name filter")
flag.StringVar(&opts.UsableType, "usable-type", "", "Optional usage type filter: RECORDING_STOCK or MARKETING_DELIVERY")
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Optional database sslmode override, for example: require")
flag.Parse()
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
opts.Report = strings.ToLower(strings.TrimSpace(opts.Report))
opts.AreaName = strings.TrimSpace(opts.AreaName)
opts.KandangLocationName = strings.TrimSpace(opts.KandangLocationName)
opts.WrongWarehouseName = strings.TrimSpace(opts.WrongWarehouseName)
opts.CorrectWarehouseName = strings.TrimSpace(opts.CorrectWarehouseName)
opts.UsableType = strings.ToUpper(strings.TrimSpace(opts.UsableType))
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
if opts.Output == "" {
opts.Output = outputModeTable
}
if opts.Output != outputModeTable && opts.Output != outputModeJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
if opts.Report == "" {
opts.Report = reportUsage
}
if opts.Report != reportUsage && opts.Report != reportWarehouses {
return nil, fmt.Errorf("unsupported --report=%s", opts.Report)
}
if opts.UsableType != "" && opts.UsableType != "RECORDING_STOCK" && opts.UsableType != "MARKETING_DELIVERY" {
return nil, fmt.Errorf("unsupported --usable-type=%s", opts.UsableType)
}
return &opts, nil
}
func loadUsageRows(ctx context.Context, db *gorm.DB, opts *options) ([]usageRow, error) {
warehouseFilters, warehouseArgs := buildWarehouseFilters(opts)
usageFilters := make([]string, 0, 1)
usageArgs := make([]any, 0, 1)
if opts.UsableType != "" {
usageFilters = append(usageFilters, "sa.usable_type = ?")
usageArgs = append(usageArgs, opts.UsableType)
}
args := append([]any{}, warehouseArgs...)
args = append(args, usageArgs...)
query := fmt.Sprintf(`
WITH wrong_warehouses AS (
SELECT
w.id AS wrong_warehouse_id,
w.name AS wrong_warehouse_name,
k.id AS kandang_id,
k.name AS kandang_name,
a.name AS area_name,
kl.name AS kandang_location_name,
correct_w.id AS correct_warehouse_id,
correct_w.name AS correct_warehouse_name
FROM warehouses w
JOIN kandangs k
ON k.id = w.kandang_id
AND k.deleted_at IS NULL
JOIN locations kl
ON kl.id = k.location_id
JOIN areas a
ON a.id = kl.area_id
JOIN LATERAL (
SELECT w2.id, w2.name
FROM warehouses w2
WHERE w2.kandang_id = w.kandang_id
AND w2.location_id = k.location_id
AND w2.deleted_at IS NULL
AND w2.id <> w.id
ORDER BY w2.id ASC
LIMIT 1
) AS correct_w ON TRUE
WHERE w.deleted_at IS NULL
AND w.kandang_id IS NOT NULL
AND w.location_id IS DISTINCT FROM k.location_id
%s
),
wrong_allocs AS (
SELECT
sa.usable_type,
sa.usable_id,
sa.qty,
pi.id AS purchase_item_id,
COALESCE(p.po_number, p.pr_number) AS purchase_number,
pr.name AS product_name,
ww.area_name,
ww.kandang_location_name,
ww.kandang_name,
ww.wrong_warehouse_id,
ww.wrong_warehouse_name,
ww.correct_warehouse_id,
ww.correct_warehouse_name
FROM stock_allocations sa
JOIN purchase_items pi
ON pi.id = sa.stockable_id
JOIN purchases p
ON p.id = pi.purchase_id
AND p.deleted_at IS NULL
JOIN products pr
ON pr.id = pi.product_id
JOIN wrong_warehouses ww
ON ww.wrong_warehouse_id = pi.warehouse_id
WHERE sa.stockable_type = 'PURCHASE_ITEMS'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.deleted_at IS NULL
%s
)
SELECT
wa.usable_type,
wa.usable_id,
wa.area_name,
wa.kandang_location_name AS lokasi_name,
wa.kandang_name,
wa.wrong_warehouse_id,
wa.wrong_warehouse_name,
wa.correct_warehouse_id,
wa.correct_warehouse_name,
STRING_AGG(DISTINCT wa.product_name, ' | ') AS product_names,
STRING_AGG(DISTINCT wa.purchase_number, ', ') AS source_purchase_numbers,
STRING_AGG(DISTINCT wa.purchase_item_id::text, ', ') AS source_purchase_item_ids,
SUM(wa.qty) AS qty_from_wrong_stock,
rs.recording_id,
TO_CHAR(r.record_datetime::date, 'YYYY-MM-DD') AS recording_date,
m.so_number,
TO_CHAR(m.so_date::date, 'YYYY-MM-DD') AS so_date
FROM wrong_allocs wa
LEFT JOIN recording_stocks rs
ON wa.usable_type = 'RECORDING_STOCK'
AND rs.id = wa.usable_id
LEFT JOIN recordings r
ON r.id = rs.recording_id
LEFT JOIN marketing_delivery_products mdp
ON wa.usable_type = 'MARKETING_DELIVERY'
AND mdp.id = wa.usable_id
LEFT JOIN marketing_products mp
ON mp.id = mdp.marketing_product_id
LEFT JOIN marketings m
ON m.id = mp.marketing_id
GROUP BY
wa.usable_type,
wa.usable_id,
wa.area_name,
wa.kandang_location_name,
wa.kandang_name,
wa.wrong_warehouse_id,
wa.wrong_warehouse_name,
wa.correct_warehouse_id,
wa.correct_warehouse_name,
rs.recording_id,
r.record_datetime,
m.so_number,
m.so_date
ORDER BY
wa.area_name ASC,
wa.kandang_location_name ASC,
wa.wrong_warehouse_name ASC,
wa.usable_type ASC,
wa.usable_id ASC
`, andClause(warehouseFilters), andClause(usageFilters))
rows := make([]usageRow, 0)
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadWarehouseMismatchRows(ctx context.Context, db *gorm.DB, opts *options) ([]warehouseMismatchRow, error) {
warehouseFilters, args := buildWarehouseFilters(opts)
query := fmt.Sprintf(`
SELECT
a.name AS area_name,
wl.name AS wrong_location_name,
kl.name AS kandang_location_name,
k.id AS kandang_id,
k.name AS kandang_name,
w.id AS wrong_warehouse_id,
w.name AS wrong_warehouse_name,
w.type AS wrong_warehouse_type,
correct_w.id AS correct_warehouse_id,
correct_w.name AS correct_warehouse_name
FROM warehouses w
JOIN kandangs k
ON k.id = w.kandang_id
AND k.deleted_at IS NULL
JOIN locations kl
ON kl.id = k.location_id
JOIN areas a
ON a.id = kl.area_id
LEFT JOIN locations wl
ON wl.id = w.location_id
JOIN LATERAL (
SELECT w2.id, w2.name
FROM warehouses w2
WHERE w2.kandang_id = w.kandang_id
AND w2.location_id = k.location_id
AND w2.deleted_at IS NULL
AND w2.id <> w.id
ORDER BY w2.id ASC
LIMIT 1
) AS correct_w ON TRUE
WHERE w.deleted_at IS NULL
AND w.kandang_id IS NOT NULL
AND w.location_id IS DISTINCT FROM k.location_id
%s
ORDER BY a.name ASC, kl.name ASC, k.name ASC, w.id ASC
`, andClause(warehouseFilters))
rows := make([]warehouseMismatchRow, 0)
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func buildWarehouseFilters(opts *options) ([]string, []any) {
filters := make([]string, 0, 4)
args := make([]any, 0, 4)
if opts == nil {
return filters, args
}
if opts.AreaName != "" {
filters = append(filters, "a.name = ?")
args = append(args, opts.AreaName)
}
if opts.KandangLocationName != "" {
filters = append(filters, "kl.name = ?")
args = append(args, opts.KandangLocationName)
}
if opts.WrongWarehouseName != "" {
filters = append(filters, "w.name = ?")
args = append(args, opts.WrongWarehouseName)
}
if opts.CorrectWarehouseName != "" {
filters = append(filters, "correct_w.name = ?")
args = append(args, opts.CorrectWarehouseName)
}
return filters, args
}
func andClause(filters []string) string {
if len(filters) == 0 {
return ""
}
return " AND " + strings.Join(filters, " AND ")
}
func renderUsageReport(mode string, rows []usageRow) {
if mode == outputModeJSON {
payload := map[string]any{
"report": reportUsage,
"rows": rows,
"summary": summarizeUsage(rows),
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "USABLE_TYPE\tUSABLE_ID\tAREA\tLOKASI\tKANDANG\tWRONG_WAREHOUSE\tCORRECT_WAREHOUSE\tPRODUCTS\tQTY_FROM_WRONG_STOCK\tRECORDING_ID\tRECORDING_DATE\tSO_NUMBER\tSO_DATE\tSOURCE_PURCHASES\tSOURCE_PURCHASE_ITEM_IDS")
for _, row := range rows {
fmt.Fprintf(
w,
"%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\t%s\t%s\t%s\t%s\n",
row.UsableType,
row.UsableID,
row.AreaName,
row.LokasiName,
row.KandangName,
row.WrongWarehouseName,
row.CorrectWarehouseName,
row.ProductNames,
row.QtyFromWrongStock,
displayOptionalUint(row.RecordingID),
displayOptionalString(row.RecordingDate),
displayOptionalString(row.SoNumber),
displayOptionalString(row.SoDate),
row.SourcePurchaseNumbers,
row.SourcePurchaseItemIDs,
)
}
_ = w.Flush()
s := summarizeUsage(rows)
fmt.Printf("\nSummary: rows=%d total_qty=%.3f\n", s.Rows, s.TotalQty)
}
func renderWarehouseReport(mode string, rows []warehouseMismatchRow) {
if mode == outputModeJSON {
payload := map[string]any{
"report": reportWarehouses,
"rows": rows,
"summary": summary{Rows: len(rows)},
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "AREA\tKANDANG_LOCATION\tKANDANG_ID\tKANDANG\tWRONG_LOCATION\tWRONG_WAREHOUSE_ID\tWRONG_WAREHOUSE\tWRONG_WAREHOUSE_TYPE\tCORRECT_WAREHOUSE_ID\tCORRECT_WAREHOUSE")
for _, row := range rows {
fmt.Fprintf(
w,
"%s\t%s\t%d\t%s\t%s\t%d\t%s\t%s\t%d\t%s\n",
row.AreaName,
row.KandangLocationName,
row.KandangID,
row.KandangName,
row.WrongLocationName,
row.WrongWarehouseID,
row.WrongWarehouseName,
row.WrongWarehouseType,
row.CorrectWarehouseID,
row.CorrectWarehouseName,
)
}
_ = w.Flush()
fmt.Printf("\nSummary: rows=%d\n", len(rows))
}
func summarizeUsage(rows []usageRow) summary {
out := summary{Rows: len(rows)}
for _, row := range rows {
out.TotalQty += row.QtyFromWrongStock
}
return out
}
func displayOptionalUint(value *uint) string {
if value == nil || *value == 0 {
return "-"
}
return fmt.Sprintf("%d", *value)
}
func displayOptionalString(value *string) string {
if value == nil || strings.TrimSpace(*value) == "" {
return "-"
}
return *value
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"fmt"
"os"
"path/filepath"
"gitlab.com/mbugroup/lti-api.git/internal/cache"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
"gitlab.com/mbugroup/lti-api.git/internal/route"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
func main() {
root, err := findRepoRoot()
if err != nil {
panic(err)
}
readapi.PrimeBuildConfig()
cache.SetRedis(redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}))
app := fiber.New(config.FiberConfig())
app.Get("/healthz", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok", "service": "api", "version": config.Version})
})
app.Get("/readyz", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok", "db": "up", "redis": "up"})
})
route.Routes(app, nil)
artifacts, err := readapi.BuildArtifactsFromApp(app)
if err != nil {
panic(err)
}
files := map[string][]byte{
filepath.Join(root, "docs", "openapi", "read-api.json"): artifacts.OpenAPIJSON,
filepath.Join(root, "docs", "openapi", "read-api.yaml"): artifacts.OpenAPIYAML,
filepath.Join(root, "docs", "postman", "read-api.collection.json"): artifacts.PostmanCollectionJSON,
filepath.Join(root, "docs", "postman", "read-api.environment.json"): artifacts.PostmanEnvironmentJSON,
}
for path, body := range files {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
panic(err)
}
if err := os.WriteFile(path, body, 0o644); err != nil {
panic(err)
}
fmt.Printf("wrote %s\n", path)
}
}
func findRepoRoot() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
current := wd
for {
if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
return "", fmt.Errorf("go.mod not found from %s", wd)
}
current = parent
}
}
+587
View File
@@ -0,0 +1,587 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
type importOptions struct {
FilePath string
Sheet string
Apply bool
}
type headerIndexes struct {
AdjustmentID int
Weight int
}
type adjustmentPriceImportRow struct {
RowNumber int
AdjustmentID uint
Weight float64
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) Error() string {
if i.Row > 0 {
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
}
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
}
type adjustmentResolver interface {
ResolveExistingAdjustmentIDs(ctx context.Context, adjustmentIDs []uint) (map[uint]struct{}, error)
}
type dbAdjustmentResolver struct {
db *gorm.DB
}
type adjustmentPriceStore interface {
UpdatePrice(ctx context.Context, adjustmentID uint, price float64) (bool, error)
}
type txRunner interface {
InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error
}
type dbTxRunner struct {
db *gorm.DB
}
type dbAdjustmentPriceStore struct {
db *gorm.DB
}
type applyRowResult struct {
RowNumber int
AdjustmentID uint
Price float64
Changed bool
}
func main() {
var opts importOptions
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
sheetName, rows, parseIssues, err := parseAdjustmentPriceFile(opts.FilePath, opts.Sheet)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
resolver := dbAdjustmentResolver{db: db}
existingAdjustmentIDs, err := resolver.ResolveExistingAdjustmentIDs(ctx, collectAdjustmentIDs(rows))
if err != nil {
log.Fatalf("failed checking adjustment_id against adjustment_stocks: %v", err)
}
processableRows, skippedRows := splitRowsByExistingIDs(rows, existingAdjustmentIDs)
issues := append([]validationIssue{}, parseIssues...)
sortValidationIssues(issues)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("Sheet: %s\n", sheetName)
fmt.Printf("Rows parsed: %d\n", len(rows))
fmt.Printf("Rows invalid: %d\n", len(issues))
fmt.Printf("Rows processable: %d\n", len(processableRows))
fmt.Printf("Rows skipped_missing: %d\n", len(skippedRows))
fmt.Println()
if len(processableRows) > 0 {
printPlanRows(processableRows)
}
if len(skippedRows) > 0 {
printSkippedRows(skippedRows)
}
if len(processableRows) > 0 || len(skippedRows) > 0 {
fmt.Println()
}
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.Error())
}
fmt.Println()
fmt.Printf(
"Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=%d\n",
len(rows),
len(processableRows),
len(skippedRows),
len(issues),
)
os.Exit(1)
}
if !opts.Apply {
fmt.Printf(
"Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=0\n",
len(rows),
len(processableRows),
len(skippedRows),
)
return
}
results, err := applyIfRequested(ctx, true, dbTxRunner{db: db}, processableRows)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
for _, result := range results {
fmt.Printf(
"DONE row=%d adjustment_id=%d price=%.3f status=%s\n",
result.RowNumber,
result.AdjustmentID,
result.Price,
applyStatus(result.Changed),
)
}
appliedCount := countChangedRows(results)
if len(results) > 0 {
fmt.Println()
}
fmt.Printf(
"Summary: planned=%d processable=%d skipped_missing=%d applied=%d failed=0\n",
len(rows),
len(processableRows),
len(skippedRows),
appliedCount,
)
}
func parseAdjustmentPriceFile(
filePath string,
requestedSheet string,
) (string, []adjustmentPriceImportRow, []validationIssue, error) {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return "", nil, nil, err
}
defer func() {
_ = workbook.Close()
}()
sheetName, err := resolveSheetName(workbook, requestedSheet)
if err != nil {
return "", nil, nil, err
}
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return "", nil, nil, err
}
if len(allRows) == 0 {
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
}
indexes, headerIssues := parseHeaderIndexes(allRows[0])
if len(headerIssues) > 0 {
return sheetName, nil, headerIssues, nil
}
rowsByAdjustmentID := make(map[uint]adjustmentPriceImportRow)
issues := make([]validationIssue, 0)
for idx := 1; idx < len(allRows); idx++ {
rowNumber := idx + 1
rawRow := allRows[idx]
if isRowEmpty(rawRow) {
continue
}
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes)
if len(rowIssues) > 0 {
issues = append(issues, rowIssues...)
continue
}
rowsByAdjustmentID[parsed.AdjustmentID] = *parsed
}
rows := make([]adjustmentPriceImportRow, 0, len(rowsByAdjustmentID))
for _, row := range rowsByAdjustmentID {
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].RowNumber < rows[j].RowNumber
})
if len(rows) == 0 && len(issues) == 0 {
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
}
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
if workbook == nil {
return "", fmt.Errorf("workbook is nil")
}
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if requestedSheet == "" {
return sheets[0], nil
}
for _, sheet := range sheets {
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
return sheet, nil
}
}
return "", fmt.Errorf("sheet %q not found", requestedSheet)
}
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
indexes := headerIndexes{AdjustmentID: -1, Weight: -1}
issues := make([]validationIssue, 0)
for idx, raw := range headerRow {
header := normalizeHeader(raw)
if header == "" {
continue
}
switch header {
case "adjustment_id":
if indexes.AdjustmentID >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header adjustment_id"})
}
indexes.AdjustmentID = idx
case "weight":
if indexes.Weight >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header weight"})
}
indexes.Weight = idx
}
}
if indexes.AdjustmentID < 0 {
issues = append(issues, validationIssue{Field: "adjustment_id", Message: "required header is missing"})
}
if indexes.Weight < 0 {
issues = append(issues, validationIssue{Field: "weight", Message: "required header is missing"})
}
return indexes, issues
}
func parseDataRow(
rawRow []string,
rowNumber int,
indexes headerIndexes,
) (*adjustmentPriceImportRow, []validationIssue) {
issues := make([]validationIssue, 0)
adjustmentIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.AdjustmentID))
adjustmentID, err := parsePositiveUint(adjustmentIDRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "adjustment_id", Message: err.Error()})
}
weightRaw := strings.TrimSpace(cellValue(rawRow, indexes.Weight))
weight, err := parseNonNegativeFloat(weightRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "weight", Message: err.Error()})
}
if len(issues) > 0 {
return nil, issues
}
return &adjustmentPriceImportRow{
RowNumber: rowNumber,
AdjustmentID: adjustmentID,
Weight: weight,
}, nil
}
func parsePositiveUint(raw string) (uint, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
uintValue, err := strconv.ParseUint(raw, 10, 64)
if err == nil {
if uintValue == 0 {
return 0, fmt.Errorf("must be greater than 0")
}
return uint(uintValue), nil
}
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, fmt.Errorf("must be a positive integer")
}
if floatValue <= 0 {
return 0, fmt.Errorf("must be greater than 0")
}
if floatValue != float64(uint(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
return uint(floatValue), nil
}
func parseNonNegativeFloat(raw string) (float64, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("must be numeric")
}
if value < 0 {
return 0, fmt.Errorf("must be greater than or equal to 0")
}
return value, nil
}
func isRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func normalizeHeader(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func collectAdjustmentIDs(rows []adjustmentPriceImportRow) []uint {
ids := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.AdjustmentID == 0 {
continue
}
if _, exists := seen[row.AdjustmentID]; exists {
continue
}
seen[row.AdjustmentID] = struct{}{}
ids = append(ids, row.AdjustmentID)
}
sort.Slice(ids, func(i, j int) bool {
return ids[i] < ids[j]
})
return ids
}
func (r dbAdjustmentResolver) ResolveExistingAdjustmentIDs(
ctx context.Context,
adjustmentIDs []uint,
) (map[uint]struct{}, error) {
result := make(map[uint]struct{})
if len(adjustmentIDs) == 0 {
return result, nil
}
type adjustmentIDRow struct {
ID uint `gorm:"column:id"`
}
rows := make([]adjustmentIDRow, 0, len(adjustmentIDs))
if err := r.db.WithContext(ctx).
Table("adjustment_stocks").
Select("id").
Where("id IN ?", adjustmentIDs).
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = struct{}{}
}
return result, nil
}
func splitRowsByExistingIDs(
rows []adjustmentPriceImportRow,
existing map[uint]struct{},
) ([]adjustmentPriceImportRow, []adjustmentPriceImportRow) {
processable := make([]adjustmentPriceImportRow, 0, len(rows))
skipped := make([]adjustmentPriceImportRow, 0)
for _, row := range rows {
if _, exists := existing[row.AdjustmentID]; exists {
processable = append(processable, row)
continue
}
skipped = append(skipped, row)
}
return processable, skipped
}
func printPlanRows(rows []adjustmentPriceImportRow) {
for _, row := range rows {
fmt.Printf(
"PLAN row=%d adjustment_id=%d price=%.3f\n",
row.RowNumber,
row.AdjustmentID,
row.Weight,
)
}
}
func printSkippedRows(rows []adjustmentPriceImportRow) {
for _, row := range rows {
fmt.Printf(
"SKIP row=%d adjustment_id=%d reason=adjustment_id not found\n",
row.RowNumber,
row.AdjustmentID,
)
}
}
func sortValidationIssues(issues []validationIssue) {
sort.Slice(issues, func(i, j int) bool {
if issues[i].Row == issues[j].Row {
if issues[i].Field == issues[j].Field {
return issues[i].Message < issues[j].Message
}
return issues[i].Field < issues[j].Field
}
return issues[i].Row < issues[j].Row
})
}
func applyIfRequested(
ctx context.Context,
apply bool,
runner txRunner,
rows []adjustmentPriceImportRow,
) ([]applyRowResult, error) {
if !apply || len(rows) == 0 {
return nil, nil
}
return applyImportRows(ctx, runner, rows)
}
func applyImportRows(
ctx context.Context,
runner txRunner,
rows []adjustmentPriceImportRow,
) ([]applyRowResult, error) {
results := make([]applyRowResult, 0, len(rows))
err := runner.InTx(ctx, func(store adjustmentPriceStore) error {
for _, row := range rows {
changed, err := store.UpdatePrice(ctx, row.AdjustmentID, row.Weight)
if err != nil {
return fmt.Errorf("row %d adjustment_id=%d update failed: %w", row.RowNumber, row.AdjustmentID, err)
}
results = append(results, applyRowResult{
RowNumber: row.RowNumber,
AdjustmentID: row.AdjustmentID,
Price: row.Weight,
Changed: changed,
})
}
return nil
})
if err != nil {
return nil, err
}
return results, nil
}
func (r dbTxRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(dbAdjustmentPriceStore{db: tx})
})
}
func (s dbAdjustmentPriceStore) UpdatePrice(
ctx context.Context,
adjustmentID uint,
price float64,
) (bool, error) {
result := s.db.WithContext(ctx).Exec(`
UPDATE adjustment_stocks
SET price = ?,
updated_at = NOW()
WHERE id = ?
AND price IS DISTINCT FROM ?
`, price, adjustmentID, price)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func applyStatus(changed bool) string {
if changed {
return "UPDATED"
}
return "UNCHANGED"
}
func countChangedRows(results []applyRowResult) int {
count := 0
for _, result := range results {
if result.Changed {
count++
}
}
return count
}
@@ -0,0 +1,362 @@
package main
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
func TestParseAdjustmentPriceFile_ValidSingleRow(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"101", "12.345"}},
)
sheet, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "adjustment_prices" {
t.Fatalf("expected selected sheet adjustment_prices, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].AdjustmentID != 101 {
t.Fatalf("expected adjustment_id 101, got %d", rows[0].AdjustmentID)
}
if rows[0].Weight != 12.345 {
t.Fatalf("expected weight 12.345, got %v", rows[0].Weight)
}
}
func TestParseAdjustmentPriceFile_ValidMultiRow(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{" Adjustment_ID ", "WEIGHT"},
[][]string{{"101", "10"}, {"102", "11.5"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "adjustment_prices")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
}
func TestParseAdjustmentPriceFile_MissingRequiredHeader(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "price"},
[][]string{{"101", "12"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
}
if !hasIssue(issues, 0, "weight", "required header is missing") {
t.Fatalf("expected missing weight header issue, got %+v", issues)
}
}
func TestParseAdjustmentPriceFile_InvalidAdjustmentID(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"abc", "10"}, {"0", "12"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "adjustment_id", "must be a positive integer") {
t.Fatalf("expected non numeric adjustment_id issue, got %+v", issues)
}
if !hasIssue(issues, 3, "adjustment_id", "must be greater than 0") {
t.Fatalf("expected adjustment_id >0 issue, got %+v", issues)
}
}
func TestParseAdjustmentPriceFile_InvalidWeight(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"101", "abc"}, {"102", "-1"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "weight", "must be numeric") {
t.Fatalf("expected weight numeric issue, got %+v", issues)
}
if !hasIssue(issues, 3, "weight", "must be greater than or equal to 0") {
t.Fatalf("expected weight >=0 issue, got %+v", issues)
}
}
func TestParseAdjustmentPriceFile_DuplicateAdjustmentID_LastRowWins(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"101", "10"}, {"102", "20"}, {"101", "30"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 deduped rows, got %d", len(rows))
}
row101, ok := findRowByAdjustmentID(rows, 101)
if !ok {
t.Fatalf("expected adjustment_id 101 to exist, got %+v", rows)
}
if row101.Weight != 30 {
t.Fatalf("expected duplicate adjustment_id to keep last weight 30, got %v", row101.Weight)
}
if row101.RowNumber != 4 {
t.Fatalf("expected duplicate adjustment_id to keep last row number 4, got %d", row101.RowNumber)
}
}
func TestSplitRowsByExistingIDs_SkipMissing(t *testing.T) {
rows := []adjustmentPriceImportRow{
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
{RowNumber: 4, AdjustmentID: 103, Weight: 12},
}
existing := map[uint]struct{}{101: {}, 103: {}}
processable, skipped := splitRowsByExistingIDs(rows, existing)
if len(processable) != 2 {
t.Fatalf("expected 2 processable rows, got %d", len(processable))
}
if len(skipped) != 1 {
t.Fatalf("expected 1 skipped row, got %d", len(skipped))
}
if skipped[0].AdjustmentID != 102 {
t.Fatalf("expected adjustment_id 102 skipped, got %+v", skipped)
}
}
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
runner := &fakeTransactionRunner{}
rows := []adjustmentPriceImportRow{{RowNumber: 2, AdjustmentID: 101, Weight: 10}}
results, err := applyIfRequested(context.Background(), false, runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if results != nil {
t.Fatalf("expected nil results on dry-run, got %+v", results)
}
if runner.txCalls != 0 {
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
}
}
func TestApplyImportRows_Success(t *testing.T) {
runner := &fakeTransactionRunner{
changedByID: map[uint]bool{101: true, 102: false},
}
rows := []adjustmentPriceImportRow{
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
}
results, err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedCalls) != 2 {
t.Fatalf("expected 2 committed updates, got %d", len(runner.committedCalls))
}
if len(results) != 2 {
t.Fatalf("expected 2 row results, got %d", len(results))
}
if !results[0].Changed || results[1].Changed {
t.Fatalf("unexpected changed flags: %+v", results)
}
}
func TestApplyImportRows_RollbackOnError(t *testing.T) {
runner := &fakeTransactionRunner{
errByID: map[uint]error{102: errors.New("boom")},
}
rows := []adjustmentPriceImportRow{
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
}
_, err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected error due to update failure")
}
if !strings.Contains(err.Error(), "row 3 adjustment_id=102 update failed") {
t.Fatalf("unexpected error message: %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedCalls) != 0 {
t.Fatalf("expected no committed updates on rollback, got %d", len(runner.committedCalls))
}
}
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
if sheetName == "" {
sheetName = defaultSheet
} else if sheetName != defaultSheet {
f.SetSheetName(defaultSheet, sheetName)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed resolving header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed resolving data cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting data cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "adjustment_prices.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed closing workbook: %v", err)
}
return path
}
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, messageContains) {
return true
}
}
return false
}
func findRowByAdjustmentID(rows []adjustmentPriceImportRow, adjustmentID uint) (adjustmentPriceImportRow, bool) {
for _, row := range rows {
if row.AdjustmentID == adjustmentID {
return row, true
}
}
return adjustmentPriceImportRow{}, false
}
type updateCall struct {
adjustmentID uint
price float64
}
type fakeAdjustmentPriceStore struct {
changedByID map[uint]bool
errByID map[uint]error
calls []updateCall
}
func (s *fakeAdjustmentPriceStore) UpdatePrice(_ context.Context, adjustmentID uint, price float64) (bool, error) {
s.calls = append(s.calls, updateCall{adjustmentID: adjustmentID, price: price})
if err, exists := s.errByID[adjustmentID]; exists {
return false, fmt.Errorf("forced update failure for adjustment_id=%d: %w", adjustmentID, err)
}
if changed, exists := s.changedByID[adjustmentID]; exists {
return changed, nil
}
return true, nil
}
type fakeTransactionRunner struct {
txCalls int
changedByID map[uint]bool
errByID map[uint]error
committedCalls []updateCall
}
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error {
r.txCalls++
txStore := &fakeAdjustmentPriceStore{
changedByID: r.changedByID,
errByID: r.errByID,
calls: make([]updateCall, 0),
}
if err := fn(txStore); err != nil {
return err
}
r.committedCalls = append(r.committedCalls, txStore.calls...)
return nil
}
var _ txRunner = (*fakeTransactionRunner)(nil)
var _ adjustmentPriceStore = (*fakeAdjustmentPriceStore)(nil)
@@ -0,0 +1,632 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/xuri/excelize/v2"
"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"
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
const dateLayout = "2006-01-02"
type importOptions struct {
FilePath string
Sheet string
Apply bool
}
type headerIndexes struct {
ProjectFlockID int
TotalCost int
CutoverDate int
Note int
}
type manualInputImportRow struct {
RowNumber int
ProjectFlockID uint
TotalCost float64
CutoverDate time.Time
Note *string
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) Error() string {
if i.Row > 0 {
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
}
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
}
type farmResolver interface {
ResolveActiveLayingFarms(ctx context.Context, projectFlockIDs []uint) (map[uint]string, error)
}
type dbFarmResolver struct {
db *gorm.DB
}
type manualInputStore interface {
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error
}
type txRunner interface {
InTx(ctx context.Context, fn func(store manualInputStore) error) error
}
type dbTxRunner struct {
db *gorm.DB
}
type expenseDepreciationStore struct {
repo repportRepo.ExpenseDepreciationRepository
}
type farmIdentityRow struct {
ID uint `gorm:"column:id"`
FarmName string `gorm:"column:farm_name"`
}
func main() {
var opts importOptions
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
log.Fatalf("failed to load timezone Asia/Jakarta: %v", err)
}
sheetName, rows, parseIssues, err := parseManualInputFile(opts.FilePath, opts.Sheet, location)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
resolver := dbFarmResolver{db: db}
farmNameByID, err := resolver.ResolveActiveLayingFarms(ctx, collectProjectFlockIDs(rows))
if err != nil {
log.Fatalf("failed validating project_flock_id against project_flocks: %v", err)
}
issues := append([]validationIssue{}, parseIssues...)
issues = append(issues, buildMissingFarmIssues(rows, farmNameByID)...)
sortValidationIssues(issues)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("Sheet: %s\n", sheetName)
fmt.Printf("Rows parsed: %d\n", len(rows))
fmt.Printf("Rows invalid: %d\n", len(issues))
fmt.Println()
if len(rows) > 0 {
printPlanRows(rows, farmNameByID)
fmt.Println()
}
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.Error())
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=0 failed=%d\n", len(rows), len(issues))
os.Exit(1)
}
if !opts.Apply {
fmt.Printf("Summary: planned=%d applied=0 failed=0\n", len(rows))
return
}
if len(rows) == 0 {
fmt.Println("Summary: planned=0 applied=0 failed=0")
return
}
if err := applyIfRequested(ctx, true, dbTxRunner{db: db}, rows); err != nil {
log.Fatalf("apply failed: %v", err)
}
for _, row := range rows {
fmt.Printf(
"DONE row=%d project_flock_id=%d cutover_date=%s\n",
row.RowNumber,
row.ProjectFlockID,
row.CutoverDate.In(location).Format(dateLayout),
)
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=%d failed=0\n", len(rows), len(rows))
}
func parseManualInputFile(
filePath string,
requestedSheet string,
location *time.Location,
) (string, []manualInputImportRow, []validationIssue, error) {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return "", nil, nil, err
}
defer func() {
_ = workbook.Close()
}()
sheetName, err := resolveSheetName(workbook, requestedSheet)
if err != nil {
return "", nil, nil, err
}
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return "", nil, nil, err
}
if len(allRows) == 0 {
return sheetName, nil, []validationIssue{
{Field: "header", Message: "sheet is empty"},
}, nil
}
indexes, headerIssues := parseHeaderIndexes(allRows[0])
if len(headerIssues) > 0 {
return sheetName, nil, headerIssues, nil
}
rows := make([]manualInputImportRow, 0, len(allRows)-1)
issues := make([]validationIssue, 0)
seenProjectFlockIDs := make(map[uint]int)
for idx := 1; idx < len(allRows); idx++ {
rowNumber := idx + 1
rawRow := allRows[idx]
if isRowEmpty(rawRow) {
continue
}
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, location, seenProjectFlockIDs)
if len(rowIssues) > 0 {
issues = append(issues, rowIssues...)
continue
}
rows = append(rows, *parsed)
}
if len(rows) == 0 && len(issues) == 0 {
issues = append(issues, validationIssue{
Field: "rows",
Message: "no data rows found",
})
}
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
if workbook == nil {
return "", fmt.Errorf("workbook is nil")
}
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if requestedSheet == "" {
return sheets[0], nil
}
for _, sheet := range sheets {
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
return sheet, nil
}
}
return "", fmt.Errorf("sheet %q not found", requestedSheet)
}
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
indexes := headerIndexes{
ProjectFlockID: -1,
TotalCost: -1,
CutoverDate: -1,
Note: -1,
}
issues := make([]validationIssue, 0)
for idx, raw := range headerRow {
header := normalizeHeader(raw)
if header == "" {
continue
}
switch header {
case "project_flock_id":
if indexes.ProjectFlockID >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header project_flock_id",
})
}
indexes.ProjectFlockID = idx
case "total_cost":
if indexes.TotalCost >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header total_cost",
})
}
indexes.TotalCost = idx
case "cutover_date":
if indexes.CutoverDate >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header cutover_date",
})
}
indexes.CutoverDate = idx
case "note":
if indexes.Note >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header note",
})
}
indexes.Note = idx
}
}
if indexes.ProjectFlockID < 0 {
issues = append(issues, validationIssue{
Field: "project_flock_id",
Message: "required header is missing",
})
}
if indexes.TotalCost < 0 {
issues = append(issues, validationIssue{
Field: "total_cost",
Message: "required header is missing",
})
}
if indexes.CutoverDate < 0 {
issues = append(issues, validationIssue{
Field: "cutover_date",
Message: "required header is missing",
})
}
return indexes, issues
}
func parseDataRow(
rawRow []string,
rowNumber int,
indexes headerIndexes,
location *time.Location,
seenProjectFlockIDs map[uint]int,
) (*manualInputImportRow, []validationIssue) {
issues := make([]validationIssue, 0)
projectFlockIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.ProjectFlockID))
projectFlockID, err := parsePositiveUint(projectFlockIDRaw)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "project_flock_id",
Message: err.Error(),
})
}
totalCostRaw := strings.TrimSpace(cellValue(rawRow, indexes.TotalCost))
totalCost, err := parseNonNegativeFloat(totalCostRaw)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "total_cost",
Message: err.Error(),
})
}
cutoverDateRaw := strings.TrimSpace(cellValue(rawRow, indexes.CutoverDate))
cutoverDate, err := parseDateOnlyInLocation(cutoverDateRaw, location)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "cutover_date",
Message: err.Error(),
})
}
var note *string
noteRaw := strings.TrimSpace(cellValue(rawRow, indexes.Note))
if noteRaw != "" {
if len([]rune(noteRaw)) > 1000 {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "note",
Message: "must have at most 1000 characters",
})
} else {
note = &noteRaw
}
}
if projectFlockID > 0 {
if previousRow, exists := seenProjectFlockIDs[projectFlockID]; exists {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "project_flock_id",
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", projectFlockID, previousRow),
})
} else {
seenProjectFlockIDs[projectFlockID] = rowNumber
}
}
if len(issues) > 0 {
return nil, issues
}
return &manualInputImportRow{
RowNumber: rowNumber,
ProjectFlockID: projectFlockID,
TotalCost: totalCost,
CutoverDate: cutoverDate,
Note: note,
}, nil
}
func parsePositiveUint(raw string) (uint, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
uintValue, err := strconv.ParseUint(raw, 10, 64)
if err == nil {
if uintValue == 0 {
return 0, fmt.Errorf("must be greater than 0")
}
return uint(uintValue), nil
}
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, fmt.Errorf("must be a positive integer")
}
if floatValue <= 0 {
return 0, fmt.Errorf("must be greater than 0")
}
if floatValue != float64(uint(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
return uint(floatValue), nil
}
func parseNonNegativeFloat(raw string) (float64, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("must be numeric")
}
if value < 0 {
return 0, fmt.Errorf("must be greater than or equal to 0")
}
return value, nil
}
func parseDateOnlyInLocation(raw string, location *time.Location) (time.Time, error) {
if raw == "" {
return time.Time{}, fmt.Errorf("is required")
}
value, err := time.ParseInLocation(dateLayout, raw, location)
if err != nil {
return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD")
}
return value, nil
}
func isRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func normalizeHeader(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func collectProjectFlockIDs(rows []manualInputImportRow) []uint {
ids := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.ProjectFlockID == 0 {
continue
}
if _, exists := seen[row.ProjectFlockID]; exists {
continue
}
seen[row.ProjectFlockID] = struct{}{}
ids = append(ids, row.ProjectFlockID)
}
sort.Slice(ids, func(i, j int) bool {
return ids[i] < ids[j]
})
return ids
}
func (r dbFarmResolver) ResolveActiveLayingFarms(
ctx context.Context,
projectFlockIDs []uint,
) (map[uint]string, error) {
result := make(map[uint]string)
if len(projectFlockIDs) == 0 {
return result, nil
}
rows := make([]farmIdentityRow, 0, len(projectFlockIDs))
if err := r.db.WithContext(ctx).
Table("project_flocks").
Select("id, flock_name AS farm_name").
Where("id IN ?", projectFlockIDs).
Where("deleted_at IS NULL").
Where("category = ?", utils.ProjectFlockCategoryLaying).
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = row.FarmName
}
return result, nil
}
func buildMissingFarmIssues(rows []manualInputImportRow, farmNameByID map[uint]string) []validationIssue {
issues := make([]validationIssue, 0)
for _, row := range rows {
if _, exists := farmNameByID[row.ProjectFlockID]; exists {
continue
}
issues = append(issues, validationIssue{
Row: row.RowNumber,
Field: "project_flock_id",
Message: fmt.Sprintf("value %d must reference an active LAYING project_flock", row.ProjectFlockID),
})
}
return issues
}
func printPlanRows(rows []manualInputImportRow, farmNameByID map[uint]string) {
for _, row := range rows {
farmName := farmNameByID[row.ProjectFlockID]
fmt.Printf(
"PLAN row=%d project_flock_id=%d farm_name=%q total_cost=%.3f cutover_date=%s note=%q\n",
row.RowNumber,
row.ProjectFlockID,
farmName,
row.TotalCost,
row.CutoverDate.Format(dateLayout),
derefString(row.Note),
)
}
}
func sortValidationIssues(issues []validationIssue) {
sort.Slice(issues, func(i, j int) bool {
if issues[i].Row == issues[j].Row {
if issues[i].Field == issues[j].Field {
return issues[i].Message < issues[j].Message
}
return issues[i].Field < issues[j].Field
}
return issues[i].Row < issues[j].Row
})
}
func applyIfRequested(ctx context.Context, apply bool, runner txRunner, rows []manualInputImportRow) error {
if !apply || len(rows) == 0 {
return nil
}
return applyImportRows(ctx, runner, rows)
}
func applyImportRows(ctx context.Context, runner txRunner, rows []manualInputImportRow) error {
return runner.InTx(ctx, func(store manualInputStore) error {
for _, row := range rows {
payload := entity.FarmDepreciationManualInput{
ProjectFlockId: row.ProjectFlockID,
TotalCost: row.TotalCost,
CutoverDate: row.CutoverDate,
Note: row.Note,
}
if err := store.UpsertManualInput(ctx, &payload); err != nil {
return fmt.Errorf("row %d project_flock_id=%d upsert failed: %w", row.RowNumber, row.ProjectFlockID, err)
}
if err := store.DeleteSnapshotsFromDate(ctx, row.CutoverDate, []uint{row.ProjectFlockID}); err != nil {
return fmt.Errorf("row %d project_flock_id=%d snapshot invalidation failed: %w", row.RowNumber, row.ProjectFlockID, err)
}
}
return nil
})
}
func (r dbTxRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
repo := repportRepo.NewExpenseDepreciationRepository(tx)
store := expenseDepreciationStore{repo: repo}
return fn(store)
})
}
func (s expenseDepreciationStore) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error {
return s.repo.UpsertManualInput(ctx, row)
}
func (s expenseDepreciationStore) DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error {
return s.repo.DeleteSnapshotsFromDate(ctx, fromDate, farmIDs)
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
@@ -0,0 +1,563 @@
package main
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/xuri/excelize/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func TestParseManualInputFile_ValidSingleRow(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "12345.678", "2026-06-01", "manual seed"},
},
)
location := mustJakartaLocation(t)
sheet, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "manual_inputs" {
t.Fatalf("expected selected sheet manual_inputs, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].ProjectFlockID != 101 {
t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID)
}
if rows[0].TotalCost != 12345.678 {
t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost)
}
if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" {
t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout))
}
if rows[0].Note == nil || *rows[0].Note != "manual seed" {
t.Fatalf("expected note manual seed, got %+v", rows[0].Note)
}
}
func TestParseManualInputFile_ValidMultiRow(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"102", "1300.5", "2026-06-02", "second"},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0].Note != nil {
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
}
if rows[1].Note == nil || *rows[1].Note != "second" {
t.Fatalf("expected second row note second, got %+v", rows[1].Note)
}
}
func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "totalcost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
}
if !hasIssue(issues, 0, "total_cost", "required header is missing") {
t.Fatalf("expected missing total_cost header issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"abc", "1200", "2026-06-01", ""},
{"0", "1300", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") {
t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues)
}
if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") {
t.Fatalf("expected project_flock_id >0 issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidTotalCost(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "abc", "2026-06-01", ""},
{"102", "-1", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "total_cost", "must be numeric") {
t.Fatalf("expected total_cost numeric issue, got %+v", issues)
}
if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") {
t.Fatalf("expected total_cost >=0 issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "06-01-2026", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") {
t.Fatalf("expected cutover_date format issue, got %+v", issues)
}
}
func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"101", "1300", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows))
}
if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") {
t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues)
}
}
func TestParseManualInputFile_NoteValidation(t *testing.T) {
longNote := strings.Repeat("a", 1001)
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"102", "1300", "2026-06-02", longNote},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected only first row valid, got %d", len(rows))
}
if rows[0].Note != nil {
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
}
if !hasIssue(issues, 3, "note", "at most 1000 characters") {
t.Fatalf("expected note length issue, got %+v", issues)
}
}
func TestApplyImportRows_Success(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 2000,
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedUpserts) != 2 {
t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts))
}
if len(runner.committedInvalidations) != 2 {
t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations))
}
if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 {
t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations)
}
}
func TestApplyImportRows_RollbackOnError(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{
failUpsertOnProjectFlockID: 102,
}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 2000,
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected error due to upsert failure")
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedUpserts) != 0 {
t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts))
}
if len(runner.committedInvalidations) != 0 {
t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations))
}
}
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
err := applyIfRequested(context.Background(), false, runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 0 {
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
}
}
func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
if sheetName == "" {
sheetName = defaultSheet
} else if sheetName != defaultSheet {
f.SetSheetName(defaultSheet, sheetName)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed resolving header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed resolving data cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting data cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "manual_inputs.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed closing workbook: %v", err)
}
return path
}
func mustJakartaLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading Asia/Jakarta location: %v", err)
}
return location
}
func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time {
t.Helper()
value, err := time.ParseInLocation(dateLayout, raw, location)
if err != nil {
t.Fatalf("failed parsing date %q: %v", raw, err)
}
return value
}
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, messageContains) {
return true
}
}
return false
}
type fakeInvalidation struct {
fromDate time.Time
farmIDs []uint
}
type fakeManualInputStore struct {
failUpsertOnProjectFlockID uint
failDeleteOnProjectFlockID uint
upserts []entity.FarmDepreciationManualInput
invalidations []fakeInvalidation
}
func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error {
if row == nil {
return nil
}
if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID {
return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId)
}
cloned := *row
s.upserts = append(s.upserts, cloned)
return nil
}
func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
if s.failDeleteOnProjectFlockID > 0 {
for _, farmID := range farmIDs {
if farmID == s.failDeleteOnProjectFlockID {
return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID)
}
}
}
copiedFarmIDs := append([]uint{}, farmIDs...)
s.invalidations = append(s.invalidations, fakeInvalidation{
fromDate: fromDate,
farmIDs: copiedFarmIDs,
})
return nil
}
type fakeTransactionRunner struct {
txCalls int
failUpsertOnProjectFlockID uint
failDeleteOnProjectFlockID uint
committedUpserts []entity.FarmDepreciationManualInput
committedInvalidations []fakeInvalidation
}
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
r.txCalls++
txStore := &fakeManualInputStore{
failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID,
failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID,
}
if err := fn(txStore); err != nil {
return err
}
r.committedUpserts = append(r.committedUpserts, txStore.upserts...)
r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...)
return nil
}
var _ txRunner = (*fakeTransactionRunner)(nil)
var _ manualInputStore = (*fakeManualInputStore)(nil)
func TestBuildMissingFarmIssues(t *testing.T) {
location := mustJakartaLocation(t)
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
issues := buildMissingFarmIssues(rows, map[uint]string{
101: "Farm A",
})
if len(issues) != 1 {
t.Fatalf("expected 1 issue, got %+v", issues)
}
if issues[0].Row != 3 || issues[0].Field != "project_flock_id" {
t.Fatalf("unexpected issue: %+v", issues[0])
}
}
func TestApplyImportRows_PropagatesDeleteError(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{
failDeleteOnProjectFlockID: 101,
}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected delete failure")
}
if !strings.Contains(err.Error(), "snapshot invalidation failed") {
t.Fatalf("expected snapshot invalidation error message, got %v", err)
}
}
func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) {
workbook := excelize.NewFile()
defer func() {
_ = workbook.Close()
}()
_, err := resolveSheetName(workbook, "unknown")
if err == nil {
t.Fatal("expected error when sheet is missing")
}
}
func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) {
location := mustJakartaLocation(t)
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
runner := &errorTxRunner{err: errors.New("tx failed")}
err := applyIfRequested(context.Background(), true, runner, rows)
if err == nil {
t.Fatal("expected transaction error")
}
if err.Error() != "tx failed" {
t.Fatalf("unexpected error: %v", err)
}
}
type errorTxRunner struct {
err error
}
func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error {
return r.err
}
+602
View File
@@ -0,0 +1,602 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
type importOptions struct {
FilePath string
Sheet string
Apply bool
}
type headerIndexes struct {
KandangID int
KandangName int
HouseType int
}
type kandangHouseTypeImportRow struct {
RowNumber int
KandangID uint
KandangName string
HouseType string
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) Error() string {
if i.Row > 0 {
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
}
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
}
type kandangResolver interface {
ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error)
}
type dbKandangResolver struct {
db *gorm.DB
}
type txRunner interface {
InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error
}
type dbTxRunner struct {
db *gorm.DB
}
type kandangHouseTypeStore interface {
UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error)
NormalizeNullHouseType(ctx context.Context) (int64, error)
}
type dbKandangHouseTypeStore struct {
db *gorm.DB
}
type kandangIdentityRow struct {
ID uint `gorm:"column:id"`
Name string `gorm:"column:name"`
}
type applyRowResult struct {
RowNumber int
KandangID uint
HouseType string
Changed bool
}
func main() {
var opts importOptions
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
sheetName, rows, parseIssues, err := parseKandangHouseTypeFile(opts.FilePath, opts.Sheet)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
resolver := dbKandangResolver{db: db}
kandangNameByID, err := resolver.ResolveActiveKandangs(ctx, collectKandangIDs(rows))
if err != nil {
log.Fatalf("failed validating kandang_id against kandangs: %v", err)
}
issues := append([]validationIssue{}, parseIssues...)
issues = append(issues, buildMissingKandangIssues(rows, kandangNameByID)...)
issues = append(issues, buildNameMismatchIssues(rows, kandangNameByID)...)
sortValidationIssues(issues)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("Sheet: %s\n", sheetName)
fmt.Printf("Rows parsed: %d\n", len(rows))
fmt.Printf("Rows invalid: %d\n", len(issues))
fmt.Println()
if len(rows) > 0 {
printPlanRows(rows, kandangNameByID)
fmt.Println()
}
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.Error())
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=%d\n", len(rows), len(issues))
os.Exit(1)
}
if !opts.Apply {
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=0\n", len(rows))
return
}
rowResults, normalizedCount, err := applyImportRows(ctx, dbTxRunner{db: db}, rows)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
for _, result := range rowResults {
fmt.Printf(
"DONE row=%d kandang_id=%d house_type=%s status=%s\n",
result.RowNumber,
result.KandangID,
result.HouseType,
applyStatus(result.Changed),
)
}
appliedCount := countChangedRows(rowResults)
fmt.Println()
fmt.Printf(
"Summary: planned=%d applied=%d normalized_null_to_open_house=%d failed=0\n",
len(rows),
appliedCount,
normalizedCount,
)
}
func parseKandangHouseTypeFile(
filePath string,
requestedSheet string,
) (string, []kandangHouseTypeImportRow, []validationIssue, error) {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return "", nil, nil, err
}
defer func() {
_ = workbook.Close()
}()
sheetName, err := resolveSheetName(workbook, requestedSheet)
if err != nil {
return "", nil, nil, err
}
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return "", nil, nil, err
}
if len(allRows) == 0 {
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
}
indexes, headerIssues := parseHeaderIndexes(allRows[0])
if len(headerIssues) > 0 {
return sheetName, nil, headerIssues, nil
}
rows := make([]kandangHouseTypeImportRow, 0, len(allRows)-1)
issues := make([]validationIssue, 0)
seenKandangIDs := make(map[uint]int)
for idx := 1; idx < len(allRows); idx++ {
rowNumber := idx + 1
rawRow := allRows[idx]
if isRowEmpty(rawRow) {
continue
}
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, seenKandangIDs)
if len(rowIssues) > 0 {
issues = append(issues, rowIssues...)
continue
}
rows = append(rows, *parsed)
}
if len(rows) == 0 && len(issues) == 0 {
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
}
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
if workbook == nil {
return "", fmt.Errorf("workbook is nil")
}
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if requestedSheet == "" {
return sheets[0], nil
}
for _, sheet := range sheets {
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
return sheet, nil
}
}
return "", fmt.Errorf("sheet %q not found", requestedSheet)
}
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
indexes := headerIndexes{KandangID: -1, KandangName: -1, HouseType: -1}
issues := make([]validationIssue, 0)
for idx, raw := range headerRow {
header := normalizeHeader(raw)
if header == "" {
continue
}
switch header {
case "kandang_id":
if indexes.KandangID >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_id"})
}
indexes.KandangID = idx
case "kandang_name":
if indexes.KandangName >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_name"})
}
indexes.KandangName = idx
case "house_type", "type_house":
if indexes.HouseType >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header house_type"})
}
indexes.HouseType = idx
}
}
if indexes.KandangID < 0 {
issues = append(issues, validationIssue{Field: "kandang_id", Message: "required header is missing"})
}
if indexes.KandangName < 0 {
issues = append(issues, validationIssue{Field: "kandang_name", Message: "required header is missing"})
}
if indexes.HouseType < 0 {
issues = append(issues, validationIssue{Field: "house_type", Message: "required header is missing"})
}
return indexes, issues
}
func parseDataRow(
rawRow []string,
rowNumber int,
indexes headerIndexes,
seenKandangIDs map[uint]int,
) (*kandangHouseTypeImportRow, []validationIssue) {
issues := make([]validationIssue, 0)
kandangIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangID))
kandangID, err := parsePositiveUint(kandangIDRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_id", Message: err.Error()})
}
kandangNameRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangName))
if kandangNameRaw == "" {
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_name", Message: "is required"})
}
houseTypeRaw := strings.TrimSpace(cellValue(rawRow, indexes.HouseType))
houseType, err := normalizeHouseType(houseTypeRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "house_type", Message: err.Error()})
}
if kandangID > 0 {
if previousRow, exists := seenKandangIDs[kandangID]; exists {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "kandang_id",
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", kandangID, previousRow),
})
} else {
seenKandangIDs[kandangID] = rowNumber
}
}
if len(issues) > 0 {
return nil, issues
}
return &kandangHouseTypeImportRow{
RowNumber: rowNumber,
KandangID: kandangID,
KandangName: kandangNameRaw,
HouseType: houseType,
}, nil
}
func normalizeHouseType(raw string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
if normalized == "" {
return string(utils.HouseTypeOpenHouse), nil
}
switch normalized {
case string(utils.HouseTypeOpenHouse), string(utils.HouseTypeCloseHouse):
return normalized, nil
default:
return "", fmt.Errorf("must be one of: open_house, close_house (or empty for default open_house)")
}
}
func parsePositiveUint(raw string) (uint, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
uintValue, err := strconv.ParseUint(raw, 10, 64)
if err == nil {
if uintValue == 0 {
return 0, fmt.Errorf("must be greater than 0")
}
return uint(uintValue), nil
}
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, fmt.Errorf("must be a positive integer")
}
if floatValue <= 0 {
return 0, fmt.Errorf("must be greater than 0")
}
if floatValue != float64(uint(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
return uint(floatValue), nil
}
func isRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func normalizeHeader(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func collectKandangIDs(rows []kandangHouseTypeImportRow) []uint {
ids := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.KandangID == 0 {
continue
}
if _, exists := seen[row.KandangID]; exists {
continue
}
seen[row.KandangID] = struct{}{}
ids = append(ids, row.KandangID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func (r dbKandangResolver) ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) {
result := make(map[uint]string)
if len(kandangIDs) == 0 {
return result, nil
}
rows := make([]kandangIdentityRow, 0, len(kandangIDs))
if err := r.db.WithContext(ctx).
Table("kandangs").
Select("id, name").
Where("id IN ?", kandangIDs).
Where("deleted_at IS NULL").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = row.Name
}
return result, nil
}
func buildMissingKandangIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
issues := make([]validationIssue, 0)
for _, row := range rows {
if _, exists := kandangNameByID[row.KandangID]; exists {
continue
}
issues = append(issues, validationIssue{
Row: row.RowNumber,
Field: "kandang_id",
Message: fmt.Sprintf("value %d must reference an active kandang", row.KandangID),
})
}
return issues
}
func buildNameMismatchIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
issues := make([]validationIssue, 0)
for _, row := range rows {
dbName, exists := kandangNameByID[row.KandangID]
if !exists {
continue
}
if strings.EqualFold(strings.TrimSpace(row.KandangName), strings.TrimSpace(dbName)) {
continue
}
issues = append(issues, validationIssue{
Row: row.RowNumber,
Field: "kandang_name",
Message: fmt.Sprintf("value %q does not match kandang_id %d name %q", row.KandangName, row.KandangID, dbName),
})
}
return issues
}
func printPlanRows(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) {
for _, row := range rows {
fmt.Printf(
"PLAN row=%d kandang_id=%d kandang_name_file=%q kandang_name_db=%q house_type=%q\n",
row.RowNumber,
row.KandangID,
row.KandangName,
kandangNameByID[row.KandangID],
row.HouseType,
)
}
}
func sortValidationIssues(issues []validationIssue) {
sort.Slice(issues, func(i, j int) bool {
if issues[i].Row == issues[j].Row {
if issues[i].Field == issues[j].Field {
return issues[i].Message < issues[j].Message
}
return issues[i].Field < issues[j].Field
}
return issues[i].Row < issues[j].Row
})
}
func applyImportRows(
ctx context.Context,
runner txRunner,
rows []kandangHouseTypeImportRow,
) ([]applyRowResult, int64, error) {
results := make([]applyRowResult, 0, len(rows))
normalizedNullCount := int64(0)
err := runner.InTx(ctx, func(store kandangHouseTypeStore) error {
for _, row := range rows {
changed, err := store.UpdateKandangHouseType(ctx, row.KandangID, row.HouseType)
if err != nil {
return fmt.Errorf("row %d kandang_id=%d update failed: %w", row.RowNumber, row.KandangID, err)
}
results = append(results, applyRowResult{
RowNumber: row.RowNumber,
KandangID: row.KandangID,
HouseType: row.HouseType,
Changed: changed,
})
}
normalized, err := store.NormalizeNullHouseType(ctx)
if err != nil {
return fmt.Errorf("normalize null house_type to open_house failed: %w", err)
}
normalizedNullCount = normalized
return nil
})
if err != nil {
return nil, 0, err
}
return results, normalizedNullCount, nil
}
func (r dbTxRunner) InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(dbKandangHouseTypeStore{db: tx})
})
}
func (s dbKandangHouseTypeStore) UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) {
result := s.db.WithContext(ctx).Exec(`
UPDATE kandangs
SET house_type = ?::house_type_enum,
updated_at = NOW()
WHERE id = ?
AND deleted_at IS NULL
AND house_type IS DISTINCT FROM ?::house_type_enum
`, houseType, kandangID, houseType)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
func (s dbKandangHouseTypeStore) NormalizeNullHouseType(ctx context.Context) (int64, error) {
result := s.db.WithContext(ctx).Exec(`
UPDATE kandangs
SET house_type = 'open_house'::house_type_enum,
updated_at = NOW()
WHERE deleted_at IS NULL
AND house_type IS NULL
`)
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func applyStatus(changed bool) string {
if changed {
return "UPDATED"
}
return "UNCHANGED"
}
func countChangedRows(results []applyRowResult) int {
count := 0
for _, item := range results {
if item.Changed {
count++
}
}
return count
}
+280
View File
@@ -0,0 +1,280 @@
package main
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
func TestParseKandangHouseTypeFile_ValidSingleRowAndDefaultHouseType(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "house_type"},
[][]string{{"101", "Kandang A1", ""}},
)
sheet, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "kandang_house_type" {
t.Fatalf("expected sheet kandang_house_type, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].KandangID != 101 {
t.Fatalf("expected kandang_id 101, got %d", rows[0].KandangID)
}
if rows[0].KandangName != "Kandang A1" {
t.Fatalf("expected kandang_name Kandang A1, got %q", rows[0].KandangName)
}
if rows[0].HouseType != "open_house" {
t.Fatalf("expected default house_type open_house, got %q", rows[0].HouseType)
}
}
func TestParseKandangHouseTypeFile_TypeHouseHeaderAlias(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "type_house"},
[][]string{{"101", "Kandang A1", "close_house"}},
)
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "kandang_house_type")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 || rows[0].HouseType != "close_house" {
t.Fatalf("expected parsed close_house row, got %+v", rows)
}
}
func TestParseKandangHouseTypeFile_InvalidHouseType(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "house_type"},
[][]string{{"101", "Kandang A1", "semi_house"}},
)
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "house_type", "must be one of") {
t.Fatalf("expected invalid house_type issue, got %+v", issues)
}
}
func TestParseKandangHouseTypeFile_DuplicateKandangID(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "house_type"},
[][]string{
{"101", "Kandang A1", "open_house"},
{"101", "Kandang A2", "close_house"},
},
)
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected first row valid and second invalid, got %d", len(rows))
}
if !hasIssue(issues, 3, "kandang_id", "duplicate value 101") {
t.Fatalf("expected duplicate kandang_id issue, got %+v", issues)
}
}
func TestBuildNameMismatchIssues(t *testing.T) {
rows := []kandangHouseTypeImportRow{{
RowNumber: 2,
KandangID: 10,
KandangName: "Kandang Salah",
HouseType: "open_house",
}}
issues := buildNameMismatchIssues(rows, map[uint]string{10: "Kandang Benar"})
if !hasIssue(issues, 2, "kandang_name", "does not match") {
t.Fatalf("expected name mismatch issue, got %+v", issues)
}
}
func TestApplyImportRows_Success(t *testing.T) {
store := &fakeStore{
changedByID: map[uint]bool{101: true, 102: false},
normalizeResult: 3,
}
runner := &fakeTransactionRunner{store: store}
rows := []kandangHouseTypeImportRow{
{RowNumber: 2, KandangID: 101, HouseType: "open_house"},
{RowNumber: 3, KandangID: 102, HouseType: "close_house"},
}
results, normalized, err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 tx call, got %d", runner.txCalls)
}
if len(results) != 2 {
t.Fatalf("expected 2 row results, got %d", len(results))
}
if normalized != 3 {
t.Fatalf("expected normalized count 3, got %d", normalized)
}
if !results[0].Changed || results[1].Changed {
t.Fatalf("unexpected changed flags: %+v", results)
}
if len(store.updateCalls) != 2 {
t.Fatalf("expected 2 update calls, got %d", len(store.updateCalls))
}
}
func TestApplyImportRows_FailOnUpdate(t *testing.T) {
store := &fakeStore{
updateErrByID: map[uint]error{101: errors.New("boom")},
}
runner := &fakeTransactionRunner{store: store}
rows := []kandangHouseTypeImportRow{{RowNumber: 2, KandangID: 101, HouseType: "open_house"}}
_, _, err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "update failed") {
t.Fatalf("expected update failed error, got %v", err)
}
}
func TestCountChangedRows(t *testing.T) {
count := countChangedRows([]applyRowResult{{Changed: true}, {Changed: false}, {Changed: true}})
if count != 2 {
t.Fatalf("expected 2 changed rows, got %d", count)
}
}
type fakeTransactionRunner struct {
store *fakeStore
txCalls int
}
func (f *fakeTransactionRunner) InTx(_ context.Context, fn func(store kandangHouseTypeStore) error) error {
f.txCalls++
return fn(f.store)
}
type updateCall struct {
kandangID uint
houseType string
}
type fakeStore struct {
updateCalls []updateCall
changedByID map[uint]bool
updateErrByID map[uint]error
normalizeResult int64
normalizeErr error
}
func (f *fakeStore) UpdateKandangHouseType(_ context.Context, kandangID uint, houseType string) (bool, error) {
f.updateCalls = append(f.updateCalls, updateCall{kandangID: kandangID, houseType: houseType})
if err, exists := f.updateErrByID[kandangID]; exists {
return false, err
}
if changed, exists := f.changedByID[kandangID]; exists {
return changed, nil
}
return true, nil
}
func (f *fakeStore) NormalizeNullHouseType(_ context.Context) (int64, error) {
if f.normalizeErr != nil {
return 0, f.normalizeErr
}
return f.normalizeResult, nil
}
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
if sheetName == "" {
sheetName = "Sheet1"
}
defaultSheet := f.GetSheetName(0)
if defaultSheet != sheetName {
idx, err := f.NewSheet(sheetName)
if err != nil {
t.Fatalf("failed creating sheet: %v", err)
}
f.SetActiveSheet(idx)
_ = f.DeleteSheet(defaultSheet)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed computing header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed computing row cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting row cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "kandang_house_type.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
return path
}
func hasIssue(issues []validationIssue, row int, field string, contains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, contains) {
return true
}
}
return false
}
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
}
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
type options struct {
FilePath string
Apply bool
}
func main() {
var opts options
flag.StringVar(&opts.FilePath, "file", "", "Path to .sql file (required)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply SQL to database. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
sqlContent, err := readSQLFile(opts.FilePath)
if err != nil {
log.Fatalf("failed reading sql file: %v", err)
}
mode := "dry-run"
if opts.Apply {
mode = "apply"
}
fmt.Printf("Mode: %s\n", mode)
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("SQL bytes: %d\n", len(sqlContent))
if !opts.Apply {
fmt.Println("Dry-run only. Add --apply to execute the SQL file.")
return
}
db := database.Connect(config.DBHost, config.DBName)
if err := executeSQL(db, sqlContent); err != nil {
log.Fatalf("failed executing sql file: %v", err)
}
fmt.Println("DONE: SQL executed successfully")
}
func readSQLFile(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", err
}
sql := strings.TrimSpace(strings.TrimPrefix(string(raw), "\ufeff"))
if sql == "" {
return "", fmt.Errorf("sql file is empty")
}
return sql, nil
}
func executeSQL(db *gorm.DB, sql string) error {
return db.Transaction(func(tx *gorm.DB) error {
return tx.Exec(sql).Error
})
}
+505
View File
@@ -0,0 +1,505 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"text/tabwriter"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
const (
outputTable = "table"
outputJSON = "json"
caseA = "A"
caseB = "B"
caseAll = "all"
)
type options struct {
Output string
AreaName string
KandangLocationName string
DBSSLMode string
VerifyCase string
}
type sourceWarehouseCheck struct {
AreaName string `json:"area_name"`
KandangLocationName string `json:"kandang_location_name"`
KandangID uint `json:"kandang_id"`
KandangName string `json:"kandang_name"`
SourceWarehouseID uint `json:"source_warehouse_id"`
SourceWarehouseName string `json:"source_warehouse_name"`
Case string `json:"case"`
DeletedAt *string `json:"deleted_at"`
StockInProductWH float64 `json:"stock_in_product_wh"`
ActivePurchaseItems int64 `json:"active_purchase_items"`
Status string `json:"status"`
}
type destinationWarehouseCheck struct {
AreaName string `json:"area_name"`
KandangLocationName string `json:"kandang_location_name"`
FarmWarehouseID uint `json:"farm_warehouse_id"`
FarmWarehouseName string `json:"farm_warehouse_name"`
ProductID uint `json:"product_id"`
ProductName string `json:"product_name"`
CurrentQty float64 `json:"current_qty"`
StockLogsTotal float64 `json:"stock_logs_total"`
StockLogsCount int64 `json:"stock_logs_count"`
Status string `json:"status"`
}
type orphanedReferenceCheck struct {
Table string `json:"table"`
Column string `json:"column"`
ReferenceCount int64 `json:"reference_count"`
DeletedWarehouseIDs string `json:"deleted_warehouse_ids"`
}
type verificationSummary struct {
TotalSourceWarehouses int `json:"total_source_warehouses"`
CleanSourceWarehouses int `json:"clean_source_warehouses"`
DirtySourceWarehouses int `json:"dirty_source_warehouses"`
TotalDestinationWarehouses int `json:"total_destination_warehouses"`
MatchingDestinations int `json:"matching_destinations"`
DiscrepancyDestinations int `json:"discrepancy_destinations"`
TotalOrphanedReferences int64 `json:"total_orphaned_references"`
OverallStatus string `json:"overall_status"`
}
func main() {
opts, err := parseFlags()
if err != nil {
log.Fatalf("invalid flags: %v", err)
}
if opts.DBSSLMode != "" {
config.DBSSLMode = opts.DBSSLMode
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
// Verify source warehouses
sourceChecks, err := verifySourceWarehouses(ctx, db, opts)
if err != nil {
log.Fatalf("failed to verify source warehouses: %v", err)
}
// Verify destination warehouses
destChecks, err := verifyDestinationWarehouses(ctx, db, opts, sourceChecks)
if err != nil {
log.Fatalf("failed to verify destination warehouses: %v", err)
}
// Verify no orphaned references
orphanedRefs, err := verifyOrphanedReferences(ctx, db, sourceChecks)
if err != nil {
log.Fatalf("failed to verify orphaned references: %v", err)
}
// Render results
summary := buildSummary(sourceChecks, destChecks, orphanedRefs)
renderVerification(opts.Output, sourceChecks, destChecks, orphanedRefs, summary)
}
func parseFlags() (*options, error) {
var opts options
flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json")
flag.StringVar(&opts.AreaName, "area-name", "", "Optional exact area name filter")
flag.StringVar(&opts.KandangLocationName, "kandang-location-name", "", "Optional exact canonical kandang location filter")
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Optional database sslmode override, for example: require")
flag.StringVar(&opts.VerifyCase, "verify-case", caseAll, "Verify specific case: A, B, or all")
flag.Parse()
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
opts.AreaName = strings.TrimSpace(opts.AreaName)
opts.KandangLocationName = strings.TrimSpace(opts.KandangLocationName)
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
opts.VerifyCase = strings.ToUpper(strings.TrimSpace(opts.VerifyCase))
if opts.Output == "" {
opts.Output = outputTable
}
if opts.Output != outputTable && opts.Output != outputJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
if opts.VerifyCase == "" {
opts.VerifyCase = caseAll
}
if opts.VerifyCase != caseA && opts.VerifyCase != caseB && opts.VerifyCase != caseAll {
return nil, fmt.Errorf("unsupported --verify-case=%s", opts.VerifyCase)
}
return &opts, nil
}
func verifySourceWarehouses(ctx context.Context, db *gorm.DB, opts *options) ([]sourceWarehouseCheck, error) {
filters := buildFilters(opts)
query := fmt.Sprintf(`
WITH case_a_warehouses AS (
-- Case A: Kandang-level warehouses (type != 'LOKASI' or NULL)
SELECT
w.id,
a.name AS area_name,
kl.name AS kandang_location_name,
k.id,
k.name,
'A'::text AS case_type
FROM warehouses w
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
JOIN locations kl ON kl.id = k.location_id
JOIN areas a ON a.id = kl.area_id
WHERE w.deleted_at IS NOT NULL
AND w.kandang_id IS NOT NULL
AND UPPER(COALESCE(w.type, '')) <> 'LOKASI'
),
case_b_warehouses AS (
-- Case B: Wrong-location warehouses (location_id != kandang.location_id)
SELECT
w.id,
a.name AS area_name,
kl.name AS kandang_location_name,
k.id,
k.name,
'B'::text AS case_type
FROM warehouses w
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
JOIN locations kl ON kl.id = k.location_id
JOIN areas a ON a.id = kl.area_id
WHERE w.deleted_at IS NOT NULL
AND w.kandang_id IS NOT NULL
AND w.location_id IS DISTINCT FROM k.location_id
),
all_source_warehouses AS (
SELECT id, area_name, kandang_location_name, id AS kandang_id, name, case_type FROM case_a_warehouses
UNION ALL
SELECT id, area_name, kandang_location_name, id AS kandang_id, name, case_type FROM case_b_warehouses
)
SELECT
asw.area_name,
asw.kandang_location_name,
asw.kandang_id,
asw.name AS kandang_name,
w.id AS source_warehouse_id,
w.name AS source_warehouse_name,
asw.case_type,
TO_CHAR(w.deleted_at, 'YYYY-MM-DD') AS deleted_at,
COALESCE(SUM(pw.qty), 0) AS stock_in_product_wh,
COUNT(DISTINCT pi.id) AS active_purchase_items
FROM all_source_warehouses asw
JOIN warehouses w ON w.id = asw.id
LEFT JOIN product_warehouses pw ON pw.warehouse_id = w.id
LEFT JOIN purchase_items pi ON pi.warehouse_id = w.id
WHERE true
%s
GROUP BY
asw.area_name,
asw.kandang_location_name,
asw.kandang_id,
asw.name,
w.id,
w.name,
asw.case_type,
w.deleted_at
ORDER BY asw.area_name ASC, asw.kandang_location_name ASC, w.name ASC
`, andClause(filters))
rows := make([]sourceWarehouseCheck, 0)
if err := db.WithContext(ctx).Raw(query).Scan(&rows).Error; err != nil {
return nil, err
}
// Determine status for each row
for i := range rows {
if rows[i].StockInProductWH == 0 && rows[i].ActivePurchaseItems == 0 {
rows[i].Status = "CLEAN"
} else {
rows[i].Status = "DIRTY"
}
// Filter by case if requested
if opts.VerifyCase != caseAll && rows[i].Case != opts.VerifyCase {
rows = append(rows[:i], rows[i+1:]...)
i--
}
}
return rows, nil
}
func verifyDestinationWarehouses(ctx context.Context, db *gorm.DB, opts *options, sourceChecks []sourceWarehouseCheck) ([]destinationWarehouseCheck, error) {
filters := buildFilters(opts)
query := fmt.Sprintf(`
SELECT
a.name AS area_name,
kl.name AS kandang_location_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
p.id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(sl.stock), 0) AS stock_logs_total,
COUNT(DISTINCT sl.id) AS stock_logs_count
FROM warehouses fw
JOIN locations loc ON loc.id = fw.location_id
JOIN areas a ON a.id = loc.area_id
JOIN kandangs k ON k.location_id = fw.location_id AND k.deleted_at IS NULL
JOIN locations kl ON kl.id = k.location_id
JOIN products p ON UPPER(COALESCE(p.type, '')) IN ('PAKAN', 'OVK')
LEFT JOIN product_warehouses pw ON pw.warehouse_id = fw.id AND pw.product_id = p.id
LEFT JOIN stock_logs sl ON sl.product_warehouse_id = pw.id
WHERE fw.deleted_at IS NULL
AND UPPER(COALESCE(fw.type, '')) = 'LOKASI'
%s
GROUP BY
a.name,
kl.name,
fw.id,
fw.name,
p.id,
p.name,
pw.qty
ORDER BY a.name ASC, kl.name ASC, fw.name ASC, p.name ASC
`, andClause(filters))
rows := make([]destinationWarehouseCheck, 0)
if err := db.WithContext(ctx).Raw(query).Scan(&rows).Error; err != nil {
return nil, err
}
// Determine status: check if current_qty matches stock_logs
for i := range rows {
if rows[i].CurrentQty > 0 {
// Allow small floating point discrepancies
if abs(rows[i].CurrentQty-rows[i].StockLogsTotal) < 0.001 {
rows[i].Status = "MATCHED"
} else {
rows[i].Status = "DISCREPANCY"
}
} else {
rows[i].Status = "EMPTY"
}
}
return rows, nil
}
func verifyOrphanedReferences(ctx context.Context, db *gorm.DB, sourceChecks []sourceWarehouseCheck) ([]orphanedReferenceCheck, error) {
if len(sourceChecks) == 0 {
return []orphanedReferenceCheck{}, nil
}
// Get unique warehouse IDs from source checks
warehouseIDs := make([]uint, 0)
for _, check := range sourceChecks {
warehouseIDs = append(warehouseIDs, check.SourceWarehouseID)
}
// Check common references
var results []orphanedReferenceCheck
refChecks := []struct {
table string
column string
}{
{"purchase_items", "warehouse_id"},
{"stock_transfers", "from_warehouse_id"},
{"stock_transfers", "to_warehouse_id"},
{"fifo_stock_v2_operation_log", "warehouse_id"},
}
for _, ref := range refChecks {
var count int64
if err := db.Table(ref.table).
Where(fmt.Sprintf("%s IN ?", ref.column), warehouseIDs).
Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
// Get the specific warehouse IDs
var ids []uint
if err := db.Table(ref.table).
Where(fmt.Sprintf("%s IN ?", ref.column), warehouseIDs).
Pluck(ref.column, &ids).Error; err != nil {
return nil, err
}
idStrs := make([]string, len(ids))
for i, id := range ids {
idStrs[i] = fmt.Sprintf("%d", id)
}
results = append(results, orphanedReferenceCheck{
Table: ref.table,
Column: ref.column,
ReferenceCount: count,
DeletedWarehouseIDs: strings.Join(idStrs, ", "),
})
}
}
return results, nil
}
func buildFilters(opts *options) []string {
filters := make([]string, 0, 2)
if opts.AreaName != "" {
filters = append(filters, fmt.Sprintf("a.name = '%s'", opts.AreaName))
}
if opts.KandangLocationName != "" {
filters = append(filters, fmt.Sprintf("kl.name = '%s'", opts.KandangLocationName))
}
return filters
}
func andClause(filters []string) string {
if len(filters) == 0 {
return ""
}
return " AND " + strings.Join(filters, " AND ")
}
func buildSummary(sourceChecks []sourceWarehouseCheck, destChecks []destinationWarehouseCheck, orphanedRefs []orphanedReferenceCheck) verificationSummary {
summary := verificationSummary{
TotalSourceWarehouses: len(sourceChecks),
OverallStatus: "PASS",
}
for _, check := range sourceChecks {
if check.Status == "CLEAN" {
summary.CleanSourceWarehouses++
} else {
summary.DirtySourceWarehouses++
summary.OverallStatus = "FAIL"
}
}
summary.TotalDestinationWarehouses = len(destChecks)
for _, check := range destChecks {
if check.Status == "MATCHED" || check.Status == "EMPTY" {
summary.MatchingDestinations++
} else if check.Status == "DISCREPANCY" {
summary.DiscrepancyDestinations++
summary.OverallStatus = "FAIL"
}
}
for _, ref := range orphanedRefs {
summary.TotalOrphanedReferences += ref.ReferenceCount
summary.OverallStatus = "FAIL"
}
return summary
}
func renderVerification(mode string, sourceChecks []sourceWarehouseCheck, destChecks []destinationWarehouseCheck, orphanedRefs []orphanedReferenceCheck, summary verificationSummary) {
if mode == outputJSON {
payload := map[string]any{
"source_warehouses": sourceChecks,
"destination_warehouses": destChecks,
"orphaned_references": orphanedRefs,
"summary": summary,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
// Table mode
fmt.Println("\n=== SOURCE WAREHOUSES VERIFICATION ===")
if len(sourceChecks) == 0 {
fmt.Println("No deleted warehouses found")
} else {
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "AREA\tLOKASI\tKANDANG\tWAREHOUSE\tCASE\tDELETED_AT\tSTOCK_IN_PW\tPURCHASE_ITEMS\tSTATUS")
for _, check := range sourceChecks {
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%d\t%s\n",
check.AreaName,
check.KandangLocationName,
check.KandangName,
check.SourceWarehouseName,
check.Case,
displayOptionalString(check.DeletedAt),
check.StockInProductWH,
check.ActivePurchaseItems,
check.Status,
)
}
_ = w.Flush()
}
fmt.Println("\n=== DESTINATION WAREHOUSES VERIFICATION ===")
if len(destChecks) == 0 {
fmt.Println("No destination warehouses found")
} else {
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "AREA\tLOKASI\tFARM_WAREHOUSE\tPRODUCT\tCURRENT_QTY\tSTOCK_LOGS_TOTAL\tLOGS_COUNT\tSTATUS")
for _, check := range destChecks {
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%.3f\t%.3f\t%d\t%s\n",
check.AreaName,
check.KandangLocationName,
check.FarmWarehouseName,
check.ProductName,
check.CurrentQty,
check.StockLogsTotal,
check.StockLogsCount,
check.Status,
)
}
_ = w.Flush()
}
if len(orphanedRefs) > 0 {
fmt.Println("\n=== ORPHANED REFERENCES (ERRORS) ===")
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "TABLE\tCOLUMN\tCOUNT\tWAREHOUSE_IDS")
for _, ref := range orphanedRefs {
fmt.Fprintf(
w,
"%s\t%s\t%d\t%s\n",
ref.Table,
ref.Column,
ref.ReferenceCount,
ref.DeletedWarehouseIDs,
)
}
_ = w.Flush()
}
fmt.Printf("\n=== SUMMARY ===\n")
fmt.Printf("Source Warehouses: %d total, %d clean, %d dirty\n", summary.TotalSourceWarehouses, summary.CleanSourceWarehouses, summary.DirtySourceWarehouses)
fmt.Printf("Destination Warehouses: %d total, %d matching, %d discrepancies\n", summary.TotalDestinationWarehouses, summary.MatchingDestinations, summary.DiscrepancyDestinations)
fmt.Printf("Orphaned References: %d\n", summary.TotalOrphanedReferences)
fmt.Printf("Overall Status: %s\n", summary.OverallStatus)
}
func displayOptionalString(value *string) string {
if value == nil || strings.TrimSpace(*value) == "" {
return "-"
}
return *value
}
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
+460
View File
@@ -0,0 +1,460 @@
# Stock Consolidation Operations Guide
This guide explains how to use the warehouse consolidation commands to fix misplaced PAKAN/OVK stocks and migrate them to the correct farm-level warehouses.
## Overview
The stock consolidation system handles two main scenarios:
| Case | Scenario | Root Cause | Solution |
|------|----------|-----------|----------|
| **Case B** | Invalid kandang references | Purchases pointed to warehouses with location mismatch | Move unused stocks to correct farm-level warehouse |
| **Case A** | General kandang cleanup | Any kandang-level warehouse with unused PAKAN/OVK stocks | Consolidate to farm-level warehouse |
## Recommended Execution Order
For a complete stock consolidation operation, follow this sequence:
```
1. find-wrong-warehouse-records ← Diagnose issues
2. repoint-wrong-warehouse-relations ← Fix Case B (invalid references)
3. consolidate-kandang-to-farm-stocks ← Fix Case A (general cleanup)
4. verify-stock-consolidation ← Audit and verify results
```
---
## Command Reference
### 1. `find-wrong-warehouse-records` — Diagnostic Tool
**Purpose:** Identify problematic warehouses and their associated stocks before making any changes.
**Applies to:** Both Case A and Case B scenarios
**What it does:**
- Lists warehouses with location mismatches (Case B)
- Shows stock allocations that reference wrong warehouses
- Helps identify scope of work needed
#### Usage:
```bash
# Report 1: Find warehouses with location mismatches (Case B issues)
./find-wrong-warehouse-records --report=warehouses
# Report 2: Find stock allocations in wrong warehouses (Case B impact)
./find-wrong-warehouse-records --report=usage
# Filter by area
./find-wrong-warehouse-records --report=warehouses --area-name "East Region"
# Filter by kandang location
./find-wrong-warehouse-records --report=usage --kandang-location-name "Location 1"
# Filter by product type
./find-wrong-warehouse-records --report=usage --usable-type=RECORDING_STOCK
# JSON output for analysis
./find-wrong-warehouse-records --report=usage --output=json > analysis.json
```
#### Output Columns (Warehouses Report):
- **AREA**: Geographic area
- **KANDANG_LOCATION**: Kandang's intended location
- **KANDANG**: Kandang name
- **WRONG_LOCATION**: Where the warehouse actually is
- **WRONG_WAREHOUSE**: Problematic warehouse name
- **CORRECT_WAREHOUSE**: Where stocks should be
#### Output Columns (Usage Report):
- **USABLE_TYPE**: RECORDING_STOCK or MARKETING_DELIVERY
- **PRODUCTS**: Which products are affected
- **QTY_FROM_WRONG_STOCK**: How much stock is misplaced
- **SOURCE_PURCHASES**: Which purchase orders are affected
**When to use:**
- Before starting any consolidation
- To understand the scope of issues
- To get metrics on how much stock needs moving
- To identify which areas are most affected
---
### 2. `repoint-wrong-warehouse-relations` — Fix Case B (Invalid References)
**Purpose:** Fix purchases pointed to invalid kandang warehouses (location mismatch).
**Applies to:** Case B only
**Cases it handles:**
- ✅ Warehouses with `location_id ≠ kandang.location_id` (location mismatch)
- ✅ Only PAKAN/OVK products
- ✅ Only unused/leftover stocks (no active allocations)
- ✅ Moves to farm-level warehouse at correct location
**What it does:**
1. Finds product_warehouses in wrong locations
2. Consolidates duplicates into survivor warehouses
3. Updates all references across the system
4. Recalculates FIFO stocks if needed
5. Optionally soft-deletes the wrong warehouse
#### Usage:
```bash
# Dry-run: See what would be moved (always run first!)
./repoint-wrong-warehouse-relations
# Dry-run with specific filters
./repoint-wrong-warehouse-relations --area-name "East Region"
./repoint-wrong-warehouse-relations --kandang-location-name "Location 1"
# Actually apply the migration
./repoint-wrong-warehouse-relations --apply
# Apply but keep the wrong warehouses (for audit trail)
./repoint-wrong-warehouse-relations --apply --delete-wrong-warehouses=false
# JSON output for automation/logging
./repoint-wrong-warehouse-relations --apply --output=json > migration.json
```
#### Flags:
- `--apply`: Apply changes (omit for dry-run)
- `--output`: `table` (default) or `json`
- `--area-name`: Filter by exact area name
- `--kandang-location-name`: Filter by exact location name
- `--delete-wrong-warehouses`: Soft-delete wrong warehouses (default: true)
- `--db-sslmode`: PostgreSQL SSL mode override (e.g., `require`)
#### Output:
**Table mode shows:**
- AREA, LOCATION, KANDANG: Where the issue is
- WRONG_WAREHOUSE: Source (will be deleted)
- TARGET_WAREHOUSE: Destination (farm-level)
- PRODUCT: What's being moved
- SURVIVOR_PW / ABSORBED_PW: Consolidation details
- NEEDS_REFLOW: Whether FIFO recalculation is needed
**Summary shows:**
```
Summary: plan_rows=15 wrong_warehouses=3 survivor_pws=12 absorbed_pws=5
needs_reflow_pws=3 deleted_product_warehouses=5 soft_deleted_warehouses=3
Updated product_warehouse refs:
fifo_stock_v2_operation_log.product_warehouse_id=8
fifo_stock_v2_reflow_checkpoints.product_warehouse_id=3
purchase_items.warehouse_id=12
Updated warehouse refs:
purchase_items.warehouse_id=12
```
#### Safety Features:
- **Dry-run first**: Always preview before applying
- **Prechecks**: Verifies no blocked references or FIFO conflicts
- **Atomic transactions**: All-or-nothing database updates
- **Reference verification**: Confirms all references were updated
- **Stock log recalculation**: Ensures FIFO accuracy after moves
---
### 3. `consolidate-kandang-to-farm-stocks` — Fix Case A (General Cleanup)
**Purpose:** Consolidate ALL kandang-level PAKAN/OVK stocks to farm-level warehouse.
**Applies to:** Case A only
**Cases it handles:**
- ✅ ALL kandang-level warehouses (type ≠ 'LOKASI')
- ✅ Only PAKAN/OVK products
- ✅ Only unused/leftover stocks (no active allocations)
- ✅ Moves to farm-level warehouse regardless of warehouse validity
- ✅ No location validation (processes all kandang warehouses)
**What it does:**
1. Finds all kandang-level warehouses with unused stocks
2. Consolidates duplicates into survivor warehouses
3. Updates all references across the system
4. Recalculates FIFO stocks if needed
5. Optionally soft-deletes the kandang warehouse
#### Usage:
```bash
# Dry-run: See what would be consolidated
./consolidate-kandang-to-farm-stocks
# Dry-run with filters
./consolidate-kandang-to-farm-stocks --area-name "East Region"
./consolidate-kandang-to-farm-stocks --kandang-location-name "Location 1"
# Actually apply the consolidation
./consolidate-kandang-to-farm-stocks --apply
# Apply but keep kandang warehouses
./consolidate-kandang-to-farm-stocks --apply --delete-kandang-warehouses=false
# JSON output for logging
./consolidate-kandang-to-farm-stocks --apply --output=json > consolidation.json
```
#### Flags:
- `--apply`: Apply changes (omit for dry-run)
- `--output`: `table` (default) or `json`
- `--area-name`: Filter by exact area name
- `--kandang-location-name`: Filter by exact location name
- `--delete-kandang-warehouses`: Soft-delete kandang warehouses (default: true)
- `--db-sslmode`: PostgreSQL SSL mode override
#### Output Format:
Similar to Case B, shows:
- Source kandang warehouse → Destination farm warehouse
- Product and quantity details
- Consolidation and FIFO reflow information
#### Key Differences from Case B:
| Aspect | Case B | Case A |
|--------|--------|--------|
| Scope | Wrong-location warehouses only | ALL kandang-level warehouses |
| Validation | Checks location mismatch | No validation checks |
| When to use | After finding mismatches | General cleanup/consolidation |
| Risk level | Lower (targeted fix) | Higher (broader scope) |
---
### 4. `verify-stock-consolidation` — Audit and Verify
**Purpose:** Verify that stock consolidations were successful and no stocks were lost.
**Applies to:** Both Case A and Case B (post-migration verification)
**What it checks:**
#### ✅ Source Warehouse Verification
Ensures deleted warehouses are clean:
- **CLEAN**: No remaining stock or purchase references
- **DIRTY**: Still has orphaned data (migration incomplete)
#### ✅ Destination Warehouse Verification
Ensures farm-level warehouses received stocks correctly:
- **MATCHED**: Quantity in product_warehouse matches stock_logs
- **DISCREPANCY**: Quantity mismatch (data integrity issue!)
- **EMPTY**: No stocks (correct if nothing was supposed to move)
#### ✅ Orphaned Reference Detection
Finds any remaining references to deleted warehouses in:
- `purchase_items.warehouse_id`
- `stock_transfers.from/to_warehouse_id`
- `fifo_stock_v2_operation_log.warehouse_id`
#### Usage:
```bash
# Verify all consolidations (Case A + B together)
./verify-stock-consolidation
# Verify only Case B results
./verify-stock-consolidation --verify-case=B
# Verify only Case A results
./verify-stock-consolidation --verify-case=A
# Filter by area
./verify-stock-consolidation --area-name "East Region"
# Filter by location
./verify-stock-consolidation --kandang-location-name "Location 1"
# JSON output for reporting
./verify-stock-consolidation --output=json > verification_report.json
```
#### Flags:
- `--verify-case`: `A`, `B`, or `all` (default)
- `--output`: `table` (default) or `json`
- `--area-name`: Filter by exact area name
- `--kandang-location-name`: Filter by exact location name
- `--db-sslmode`: PostgreSQL SSL mode override
#### Output Sections:
**1. Source Warehouses**
```
AREA LOKASI KANDANG WAREHOUSE CASE DELETED_AT STOCK PURCHASES STATUS
Area A Location 1 Kandang A KWH-A-01 A 2026-04-23 0.000 0 CLEAN
Area A Location 1 Kandang B WH-WRONG-001 B 2026-04-23 2.500 1 DIRTY ❌
```
**2. Destination Warehouses**
```
AREA LOKASI FARM_WAREHOUSE PRODUCT QTY LOGS_TOTAL LOGS STATUS
Area A Location 1 FWH-LOC-001 PAKAN A 2.500 2.500 3 MATCHED ✅
Area A Location 1 FWH-LOC-001 OVK B 5.000 4.999 5 DISCREPANCY ❌
```
**3. Orphaned References** (if any)
```
TABLE COLUMN COUNT WAREHOUSE_IDS
purchase_items warehouse_id 3 1001, 1002, 1003
stock_transfers from_warehouse_id 1 1001
```
**4. Summary**
```
Source Warehouses: 10 total, 8 clean, 2 dirty
Destination Warehouses: 15 total, 14 matching, 1 discrepancy
Orphaned References: 4
Overall Status: FAIL ❌
```
#### Interpreting Results:
| Scenario | Meaning | Action |
|----------|---------|--------|
| ✅ Overall Status: PASS | All migrations successful | No action needed |
| ❌ Dirty Source Warehouses | Stocks not fully moved | Re-run repoint/consolidate |
| ❌ Discrepancy Destinations | Quantity mismatch | Investigate data integrity |
| ❌ Orphaned References | Broken references remain | Manual cleanup needed |
---
## Complete Workflow Example
### Scenario: Consolidate East Region stocks
```bash
# Step 1: Understand the scope (Case B issues)
./find-wrong-warehouse-records --report=warehouses --area-name "East Region"
./find-wrong-warehouse-records --report=usage --area-name "East Region"
# Review the output to understand:
# - How many wrong warehouses
# - How much stock needs moving
# - Which products are affected
# Step 2: Fix Case B (invalid kandang references)
./repoint-wrong-warehouse-relations --area-name "East Region"
# Review dry-run output
./repoint-wrong-warehouse-relations --apply --area-name "East Region"
# Watch for summary - should show successful updates
# Step 3: Fix Case A (general kandang cleanup)
./consolidate-kandang-to-farm-stocks --area-name "East Region"
# Review dry-run output
./consolidate-kandang-to-farm-stocks --apply --area-name "East Region"
# Watch for summary - should show consolidation complete
# Step 4: Verify everything worked
./verify-stock-consolidation --area-name "East Region"
# Should show:
# - All source warehouses: CLEAN
# - All destination warehouses: MATCHED
# - Orphaned references: 0
# - Overall Status: PASS ✅
```
---
## Flags Reference
### Common Flags (All Commands)
| Flag | Description | Example |
|------|-------------|---------|
| `--output` | Output format | `--output=json` |
| `--area-name` | Filter by area | `--area-name "East Region"` |
| `--kandang-location-name` | Filter by location | `--kandang-location-name "Location 1"` |
| `--db-sslmode` | PostgreSQL SSL mode | `--db-sslmode=require` |
### Migration-Specific Flags
| Command | Flag | Description |
|---------|------|-------------|
| `repoint-wrong-warehouse-relations` | `--apply` | Apply changes |
| `repoint-wrong-warehouse-relations` | `--delete-wrong-warehouses` | Delete wrong warehouses (default: true) |
| `consolidate-kandang-to-farm-stocks` | `--apply` | Apply changes |
| `consolidate-kandang-to-farm-stocks` | `--delete-kandang-warehouses` | Delete kandang warehouses (default: true) |
| `verify-stock-consolidation` | `--verify-case` | Verify specific case (A, B, or all) |
---
## Best Practices
### Before Running Any Command
1. **Back up the database** — These operations modify stock data
2. **Run in dry-run mode first** — Always preview changes before applying
3. **Check during low-traffic periods** — Avoid peak hours
4. **Have a rollback plan** — Know how to restore from backup if needed
### When Running Migrations
1. **Start small** — Use `--area-name` to test on one area first
2. **Check the summary** — Verify numbers make sense
3. **Watch for errors** — Stop if you see unexpected error messages
4. **Run verification immediately after** — Don't wait to verify
### Red Flags (Stop and Investigate)
- ❌ More rows affected than expected
- ❌ Negative quantities or zero counts where expecting data
- ❌ Errors about blocked references
- ❌ FIFO conflicts or in-flight artifacts
- ❌ Very large numbers in NEEDS_REFLOW
### JSON Output for Automation
All commands support `--output=json` for:
- Piping to other tools
- Parsing in scripts
- Generating reports
- Integration with monitoring systems
```bash
# Example: Extract all affected warehouses to CSV
./find-wrong-warehouse-records --report=warehouses --output=json \
| jq -r '.rows[] | [.area_name, .kandang_name, .wrong_warehouse_name] | @csv' \
> affected_warehouses.csv
```
---
## Troubleshooting
### Issue: "No wrong warehouse relations found"
- **Cause**: No matching Case B issues in the filter scope
- **Solution**: Remove filters or use different criteria
### Issue: "found X rows still point to wrong warehouses"
- **Cause**: References not fully migrated
- **Solution**: Check for blocked references, re-run command
### Issue: "discrepancy_destinations > 0" in verification
- **Cause**: Quantity mismatch in farm warehouse
- **Solution**: Investigate manually or rollback and retry
### Issue: "DIRTY source warehouses" in verification
- **Cause**: Deleted warehouses still have stock/references
- **Solution**: May need manual cleanup or re-run migrations
---
## Performance Notes
- Commands use efficient SQL queries with proper filtering
- Large operations (100K+ rows) may take a few minutes
- Use area/location filters to reduce scope for testing
- Dry-runs don't modify database and complete quickly
## Support
For issues or questions:
1. Review the relevant section of this guide
2. Check the command output for specific error messages
3. Run verification to diagnose state issues
4. Contact the development team with JSON outputs from failed operations
@@ -0,0 +1,76 @@
# Farm Depreciation Manual Inputs Import
Command ini dipakai untuk bulk import data ke tabel `farm_depreciation_manual_inputs` dari file Excel (`.xlsx`).
## Command
```bash
go run ./cmd/import-farm-depreciation-manual-inputs --file <path.xlsx> [--sheet <name>] [--apply]
```
## Flags
- `--file` (required): path file `.xlsx`.
- `--sheet` (optional): nama sheet. Jika tidak diisi, command pakai sheet pertama.
- `--apply` (optional): default `false` (dry-run). Jika `true`, command menulis ke database.
## Mode
- Dry-run (default):
- parsing dan validasi semua baris.
- validasi `project_flock_id` terhadap farm aktif kategori `LAYING`.
- menampilkan `PLAN` + daftar error.
- tidak menulis data.
- Apply (`--apply`):
- semua validasi tetap dijalankan dulu.
- jika ada 1 error, proses dihentikan.
- jika valid, upsert dijalankan dalam 1 transaksi (fail-fast).
- setelah upsert, snapshot di `farm_depreciation_snapshots` dihapus mulai `cutover_date` untuk `project_flock_id` terkait.
## Format Excel
Template tersedia di:
- `docs/templates/farm_depreciation_manual_inputs.xlsx`
Header wajib ada di baris 1 (case-insensitive, trim-spaces):
- `project_flock_id` (required, integer > 0)
- `total_cost` (required, numeric >= 0)
- `cutover_date` (required, format `YYYY-MM-DD`)
- `note` (optional, max 1000 karakter)
Catatan:
- Dalam 1 file tidak boleh ada duplikat `project_flock_id`.
- `project_flock_id` harus mengarah ke `project_flocks` yang `deleted_at IS NULL` dan `category = LAYING`.
## Contoh
Dry-run:
```bash
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
go run ./cmd/import-farm-depreciation-manual-inputs \
--file docs/templates/farm_depreciation_manual_inputs.xlsx
```
Apply:
```bash
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
go run ./cmd/import-farm-depreciation-manual-inputs \
--file /path/to/farm_depreciation_manual_inputs.xlsx \
--sheet manual_inputs \
--apply
```
## Error Umum
- `required header is missing`: header wajib tidak ditemukan.
- `must be a positive integer`: `project_flock_id` bukan integer valid.
- `must be greater than or equal to 0`: `total_cost` negatif.
- `must follow format YYYY-MM-DD`: `cutover_date` tidak sesuai format.
- `duplicate value ...`: `project_flock_id` duplikat dalam file yang sama.
- `must reference an active LAYING project_flock`: farm tidak valid untuk import ini.
+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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
{
"_postman_exported_at": "2026-04-14T00:00:00Z",
"_postman_exported_using": "Codex",
"_postman_variable_scope": "environment",
"id": "lti-read-api-local",
"name": "LTI ERP Read API.local",
"values": [
{
"enabled": true,
"key": "adjustment_id",
"value": "1"
},
{
"enabled": true,
"key": "api_key",
"value": ""
},
{
"enabled": true,
"key": "area_id",
"value": "1"
},
{
"enabled": true,
"key": "bank_id",
"value": "1"
},
{
"enabled": true,
"key": "base_url",
"value": "http://localhost:8081"
},
{
"enabled": true,
"key": "bearer_token",
"value": ""
},
{
"enabled": true,
"key": "chickin_id",
"value": "1"
},
{
"enabled": true,
"key": "customer_id",
"value": "1"
},
{
"enabled": true,
"key": "employee_id",
"value": "1"
},
{
"enabled": true,
"key": "expense_id",
"value": "1"
},
{
"enabled": true,
"key": "flock_id",
"value": "1"
},
{
"enabled": true,
"key": "id",
"value": "1"
},
{
"enabled": true,
"key": "idDailyChecklist",
"value": "1"
},
{
"enabled": true,
"key": "idProjectFlockKandang",
"value": "1"
},
{
"enabled": true,
"key": "initial_balance_id",
"value": "1"
},
{
"enabled": true,
"key": "injection_id",
"value": "1"
},
{
"enabled": true,
"key": "location_id",
"value": "1"
},
{
"enabled": true,
"key": "nonstock_id",
"value": "1"
},
{
"enabled": true,
"key": "payment_id",
"value": "1"
},
{
"enabled": true,
"key": "product_category_id",
"value": "1"
},
{
"enabled": true,
"key": "product_id",
"value": "1"
},
{
"enabled": true,
"key": "projectFlockId",
"value": "1"
},
{
"enabled": true,
"key": "project_flock_id",
"value": "1"
},
{
"enabled": true,
"key": "project_flock_kandang_id",
"value": "1"
},
{
"enabled": true,
"key": "purchase_id",
"value": "1"
},
{
"enabled": true,
"key": "recording_id",
"value": "1"
},
{
"enabled": true,
"key": "supplier_id",
"value": "1"
},
{
"enabled": true,
"key": "transaction_id",
"value": "1"
},
{
"enabled": true,
"key": "transfer_id",
"value": "1"
},
{
"enabled": true,
"key": "uniformity_id",
"value": "1"
},
{
"enabled": true,
"key": "uom_id",
"value": "1"
},
{
"enabled": true,
"key": "user_id",
"value": "1"
},
{
"enabled": true,
"key": "warehouse_id",
"value": "1"
}
]
}
+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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
package apikeys
func DefaultDashboardPermissions() []string {
return []string{
"lti.approval.list",
"lti.closing.list",
"lti.closing.detail",
"lti.daily_checklist.create",
"lti.daily_checklist.dashboard.list",
"lti.daily_checklist.detail",
"lti.daily_checklist.list",
"lti.daily_checklist.master_data.activity",
"lti.daily_checklist.master_data.configuration",
"lti.daily_checklist.master_data.employee",
"lti.daily_checklist.reports",
"lti.dashboard.list",
"lti.expense.detail",
"lti.expense.list",
"lti.finance.initial_balances.detail",
"lti.finance.injections.detail",
"lti.finance.payments.detail",
"lti.finance.transactions.detail",
"lti.finance.transactions.list",
"lti.inventory.detail",
"lti.inventory.list",
"lti.inventory.product_stock.detail",
"lti.inventory.product_stock.list",
"lti.inventory.product_warehouses.detail",
"lti.inventory.product_warehouses.list",
"lti.inventory.transfer.detail",
"lti.inventory.transfer.list",
"lti.marketing.delivery_order.detail",
"lti.marketing.delivery_order.list",
"lti.master.area.detail",
"lti.master.area.list",
"lti.master.banks.detail",
"lti.master.banks.list",
"lti.master.customer.detail",
"lti.master.customer.list",
"lti.master.fcr.detail",
"lti.master.fcr.list",
"lti.master.flocks.detail",
"lti.master.flocks.list",
"lti.master.kandangs.detail",
"lti.master.kandangs.list",
"lti.master.locations.detail",
"lti.master.locations.list",
"lti.master.nonstocks.detail",
"lti.master.nonstocks.list",
"lti.master.product_categories.detail",
"lti.master.product_categories.list",
"lti.master.products.detail",
"lti.master.products.list",
"lti.master.production_standards.detail",
"lti.master.production_standards.list",
"lti.master.suppliers.detail",
"lti.master.suppliers.list",
"lti.master.uoms.detail",
"lti.master.uoms.list",
"lti.master.warehouses.detail",
"lti.master.warehouses.list",
"lti.production.chickins.detail",
"lti.production.project_flock_kandangs.closing.detail",
"lti.production.project_flock_kandangs.detail",
"lti.production.project_flock_kandangs.list",
"lti.production.project_flocks.detail",
"lti.production.project_flocks.list",
"lti.production.project_flocks.lookup",
"lti.production.project_flocks.next_period",
"lti.production.recording.detail",
"lti.production.recording.list",
"lti.production.recording.next_day",
"lti.production.transfer_to_laying.create",
"lti.production.transfer_to_laying.detail",
"lti.production.transfer_to_laying.getavailableqty",
"lti.production.transfer_to_laying.list",
"lti.production.uniformity.detail",
"lti.production.uniformity.list",
"lti.purchase.detail",
"lti.purchase.list",
"lti.repport.customerpayment.list",
"lti.repport.debtsupplier.list",
"lti.repport.delivery.list",
"lti.repport.expense.list",
"lti.repport.expense.depreciation.manage",
"lti.repport.gethppperkandang.list",
"lti.repport.production_result.list",
"lti.repport.purchasesupplier.list",
"lti.users.detail",
"lti.users.list",
"lti.daily_checklist.master_data.kandang",
}
}
+107
View File
@@ -0,0 +1,107 @@
package apikeys
import (
"context"
"errors"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type Repository interface {
Create(ctx context.Context, record *entity.IntegrationAPIKey) error
GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error)
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error
TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error
}
type repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &repository{db: db}
}
func (r *repository) Create(ctx context.Context, record *entity.IntegrationAPIKey) error {
if r.db == nil {
return errors.New("database not configured")
}
return r.db.WithContext(ctx).Create(record).Error
}
func (r *repository) GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error) {
if r.db == nil {
return nil, errors.New("database not configured")
}
var record entity.IntegrationAPIKey
if err := r.db.WithContext(ctx).
Where("environment = ?", environment).
Where("key_prefix = ?", prefix).
First(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
func (r *repository) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
if r.db == nil {
return nil, errors.New("database not configured")
}
query := r.db.WithContext(ctx).Model(&entity.IntegrationAPIKey{})
if environment != "" {
query = query.Where("environment = ?", environment)
}
var records []entity.IntegrationAPIKey
if err := query.Order("environment ASC").Order("name ASC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func (r *repository) Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error {
if r.db == nil {
return errors.New("database not configured")
}
updates := map[string]any{
"status": entity.IntegrationAPIKeyStatusRevoked,
"revoked_at": revokedAt,
"updated_at": revokedAt,
}
result := r.db.WithContext(ctx).
Model(&entity.IntegrationAPIKey{}).
Where("environment = ?", environment).
Where("key_prefix = ?", prefix).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *repository) TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error {
if r.db == nil {
return errors.New("database not configured")
}
return r.db.WithContext(ctx).
Model(&entity.IntegrationAPIKey{}).
Where("id = ?", id).
Updates(map[string]any{
"last_used_at": usedAt,
"last_used_from": usedFrom,
"updated_at": usedAt,
}).Error
}
+233
View File
@@ -0,0 +1,233 @@
package apikeys
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/secure"
"gorm.io/gorm"
)
var (
ErrInvalidAPIKey = errors.New("invalid api key")
ErrInactiveKey = errors.New("inactive api key")
)
type Principal struct {
ID uint
Name string
Environment string
Permissions []string
AllArea bool
AreaIDs []uint
AllLocation bool
LocationIDs []uint
}
type Authenticator interface {
Authenticate(ctx context.Context, rawKey, source string) (*Principal, error)
}
type Service interface {
Authenticator
Create(ctx context.Context, input CreateInput) (*IssuedKey, error)
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
Revoke(ctx context.Context, environment, prefix string) error
}
type CreateInput struct {
Name string
Environment string
PermissionCodes []string
AllArea bool
AreaIDs []uint
AllLocation bool
LocationIDs []uint
}
type IssuedKey struct {
Key string
Record *entity.IntegrationAPIKey
}
type service struct {
repo Repository
now func() time.Time
}
func NewService(db *gorm.DB) Service {
return &service{
repo: NewRepository(db),
now: time.Now,
}
}
func (s *service) Authenticate(ctx context.Context, rawKey, source string) (*Principal, error) {
environment, prefix, secret, err := parseRawKey(rawKey)
if err != nil {
return nil, ErrInvalidAPIKey
}
record, err := s.repo.GetByEnvironmentAndPrefix(ctx, environment, prefix)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidAPIKey
}
return nil, err
}
if !strings.EqualFold(record.Status, entity.IntegrationAPIKeyStatusActive) || record.RevokedAt != nil {
return nil, ErrInactiveKey
}
if !secure.Verify(record.KeyHash, secret) {
return nil, ErrInvalidAPIKey
}
usedAt := s.now().UTC()
if err := s.repo.TouchLastUsed(ctx, record.ID, usedAt, strings.TrimSpace(source)); err != nil {
utils.Log.WithError(err).Warn("api key: failed to update last_used fields")
}
return &Principal{
ID: record.ID,
Name: record.Name,
Environment: record.Environment,
Permissions: canonicalPermissions(record.PermissionCodes),
AllArea: record.AllArea,
AreaIDs: uniqueUint(record.AreaIDs),
AllLocation: record.AllLocation,
LocationIDs: uniqueUint(record.LocationIDs),
}, nil
}
func (s *service) Create(ctx context.Context, input CreateInput) (*IssuedKey, error) {
name := strings.TrimSpace(input.Name)
environment := strings.ToLower(strings.TrimSpace(input.Environment))
if name == "" || environment == "" {
return nil, fmt.Errorf("name and environment are required")
}
prefix, err := randomToken(10)
if err != nil {
return nil, err
}
secret, err := randomToken(24)
if err != nil {
return nil, err
}
hash, err := secure.Hash(secret, nil)
if err != nil {
return nil, err
}
record := &entity.IntegrationAPIKey{
Name: name,
Environment: environment,
Status: entity.IntegrationAPIKeyStatusActive,
KeyPrefix: prefix,
KeyHash: hash,
PermissionCodes: canonicalPermissions(input.PermissionCodes),
AllArea: input.AllArea,
AreaIDs: uniqueUint(input.AreaIDs),
AllLocation: input.AllLocation,
LocationIDs: uniqueUint(input.LocationIDs),
}
if err := s.repo.Create(ctx, record); err != nil {
return nil, err
}
return &IssuedKey{
Key: fmt.Sprintf("lti_%s_%s_%s", environment, prefix, secret),
Record: record,
}, nil
}
func (s *service) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
return s.repo.List(ctx, strings.ToLower(strings.TrimSpace(environment)))
}
func (s *service) Revoke(ctx context.Context, environment, prefix string) error {
environment = strings.ToLower(strings.TrimSpace(environment))
prefix = strings.TrimSpace(prefix)
if environment == "" || prefix == "" {
return fmt.Errorf("environment and prefix are required")
}
return s.repo.Revoke(ctx, environment, prefix, s.now().UTC())
}
func parseRawKey(rawKey string) (environment string, prefix string, secret string, err error) {
rawKey = strings.TrimSpace(rawKey)
parts := strings.Split(rawKey, "_")
if len(parts) != 4 || parts[0] != "lti" {
return "", "", "", ErrInvalidAPIKey
}
environment = strings.ToLower(strings.TrimSpace(parts[1]))
prefix = strings.TrimSpace(parts[2])
secret = strings.TrimSpace(parts[3])
if environment == "" || prefix == "" || secret == "" {
return "", "", "", ErrInvalidAPIKey
}
return environment, prefix, secret, nil
}
func randomToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
return strings.ToLower(encoder.EncodeToString(buf)), nil
}
func canonicalPermissions(perms []string) []string {
if len(perms) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(perms))
result := make([]string, 0, len(perms))
for _, perm := range perms {
perm = strings.ToLower(strings.TrimSpace(perm))
if perm == "" {
continue
}
if _, ok := seen[perm]; ok {
continue
}
seen[perm] = struct{}{}
result = append(result, perm)
}
return result
}
func uniqueUint(values []uint) []uint {
if len(values) == 0 {
return []uint{}
}
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
+604
View File
@@ -0,0 +1,604 @@
package exportprogress
import (
"fmt"
"sort"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const (
UnassignedKandangName = "Farm-level / Unassigned"
jakartaTZ = "Asia/Jakarta"
)
type Query struct {
StartDate time.Time
EndDate time.Time
StartDateRaw string
EndDateRaw string
}
type Row struct {
Module string
FarmName string
KandangName string
ActivityDate time.Time
Count int
}
type monthBlock struct {
Start time.Time
Weeks int
}
func IsProgressExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "progress")
}
func ParseQuery(c *fiber.Ctx) (*Query, error) {
location, err := time.LoadLocation(jakartaTZ)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
}
startRaw := strings.TrimSpace(c.Query("start_date"))
endRaw := strings.TrimSpace(c.Query("end_date"))
if startRaw == "" || endRaw == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "start_date and end_date are required")
}
startDate, err := time.ParseInLocation("2006-01-02", startRaw, location)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "start_date must use format YYYY-MM-DD")
}
endDate, err := time.ParseInLocation("2006-01-02", endRaw, location)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "end_date must use format YYYY-MM-DD")
}
if endDate.Before(startDate) {
return nil, fiber.NewError(fiber.StatusBadRequest, "end_date must be greater than or equal to start_date")
}
return &Query{
StartDate: startDate,
EndDate: endDate,
StartDateRaw: startRaw,
EndDateRaw: endRaw,
}, nil
}
func BuildWorkbook(moduleTitle string, query *Query, rows []Row) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
sheetName := moduleTitle
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != sheetName {
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
return nil, err
}
}
location, err := time.LoadLocation(jakartaTZ)
if err != nil {
return nil, err
}
titleStyle, metaStyle, monthStyle, weekStyle, dayHeaderStyle, farmStyle, textStyle, numberStyle, subtotalStyle, err := buildStyles(file)
if err != nil {
return nil, err
}
months := monthBlocksBetween(query.StartDate, query.EndDate)
maxWeeks := 4
for _, block := range months {
if block.Weeks > maxWeeks {
maxWeeks = block.Weeks
}
}
lastColName, err := excelize.ColumnNumberToName(1 + (maxWeeks * 7) + 1)
if err != nil {
return nil, err
}
if err := file.MergeCell(sheetName, "A1", lastColName+"1"); err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, "A1", moduleTitle); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A1", lastColName+"1", titleStyle); err != nil {
return nil, err
}
metaValue := fmt.Sprintf(
"Range: %s to %s | Generated at: %s",
query.StartDateRaw,
query.EndDateRaw,
time.Now().In(location).Format("2006-01-02 15:04:05 MST"),
)
if err := file.MergeCell(sheetName, "A2", lastColName+"2"); err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, "A2", metaValue); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A2", lastColName+"2", metaStyle); err != nil {
return nil, err
}
if err := applyColumnWidths(file, sheetName, maxWeeks); err != nil {
return nil, err
}
grouped := groupRows(rows)
currentRow := 4
for _, month := range months {
lastColIndex := 1 + (month.Weeks * 7) + 1
monthLastCol, err := excelize.ColumnNumberToName(lastColIndex)
if err != nil {
return nil, err
}
if err := renderMonthHeader(file, sheetName, currentRow, month, monthLastCol, monthStyle, weekStyle, dayHeaderStyle); err != nil {
return nil, err
}
currentRow += 4
monthData := grouped[month.Start.Format("2006-01")]
if len(monthData) == 0 {
if err := file.MergeCell(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow)); err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), "No progress data"); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), textStyle); err != nil {
return nil, err
}
currentRow += 2
continue
}
farms := sortedKeys(monthData)
for _, farm := range farms {
if err := file.MergeCell(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow)); err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), farm); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), farmStyle); err != nil {
return nil, err
}
currentRow++
kandangs := sortedKeys(monthData[farm])
farmTotals := make(map[string]int)
farmGrandTotal := 0
for _, kandang := range kandangs {
rowCounts := monthData[farm][kandang]
rowTotal := 0
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), kandang); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), "A"+fmt.Sprint(currentRow), textStyle); err != nil {
return nil, err
}
for dayKey, count := range rowCounts {
activityDate, err := time.ParseInLocation("2006-01-02", dayKey, location)
if err != nil {
return nil, err
}
colIndex := dayColumnIndex(month, activityDate)
colName, err := excelize.ColumnNumberToName(colIndex)
if err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, colName+fmt.Sprint(currentRow), count); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, colName+fmt.Sprint(currentRow), colName+fmt.Sprint(currentRow), numberStyle); err != nil {
return nil, err
}
rowTotal += count
farmTotals[dayKey] += count
farmGrandTotal += count
}
if err := file.SetCellValue(sheetName, monthLastCol+fmt.Sprint(currentRow), rowTotal); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, monthLastCol+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), subtotalStyle); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "B"+fmt.Sprint(currentRow), prevColumn(monthLastCol)+fmt.Sprint(currentRow), numberStyle); err != nil {
return nil, err
}
currentRow++
}
if err := file.SetCellValue(sheetName, "A"+fmt.Sprint(currentRow), "Subtotal"); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), "A"+fmt.Sprint(currentRow), subtotalStyle); err != nil {
return nil, err
}
for dayKey, count := range farmTotals {
activityDate, err := time.ParseInLocation("2006-01-02", dayKey, location)
if err != nil {
return nil, err
}
colIndex := dayColumnIndex(month, activityDate)
colName, err := excelize.ColumnNumberToName(colIndex)
if err != nil {
return nil, err
}
if err := file.SetCellValue(sheetName, colName+fmt.Sprint(currentRow), count); err != nil {
return nil, err
}
}
if err := file.SetCellValue(sheetName, monthLastCol+fmt.Sprint(currentRow), farmGrandTotal); err != nil {
return nil, err
}
if err := file.SetCellStyle(sheetName, "A"+fmt.Sprint(currentRow), monthLastCol+fmt.Sprint(currentRow), subtotalStyle); err != nil {
return nil, err
}
currentRow += 2
}
}
if err := file.SetPanes(sheetName, &excelize.Panes{
Freeze: true,
YSplit: 2,
TopLeftCell: "A3",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func ParseActivityDate(value string) (time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return time.Time{}, fmt.Errorf("empty activity date")
}
layouts := []string{
"2006-01-02",
time.RFC3339,
time.RFC3339Nano,
"2006-01-02 15:04:05Z07:00",
"2006-01-02 15:04:05.999999999Z07:00",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, trimmed); err == nil {
return parsed, nil
}
}
if len(trimmed) >= len("2006-01-02") {
if parsed, err := time.Parse("2006-01-02", trimmed[:10]); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("unsupported activity date format: %s", value)
}
func buildStyles(file *excelize.File) (int, int, int, int, int, int, int, int, int, error) {
titleStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 18, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
metaStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Italic: true, Color: "4B5563"},
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
monthStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"1D4ED8"}},
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
weekStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DBEAFE"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{{Type: "bottom", Color: "93C5FD", Style: 1}},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
dayHeaderStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "374151"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"EFF6FF"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "BFDBFE", Style: 1},
{Type: "top", Color: "BFDBFE", Style: 1},
{Type: "bottom", Color: "BFDBFE", Style: 1},
{Type: "right", Color: "BFDBFE", Style: 1},
},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
farmStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "111827"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E5E7EB"}},
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
textStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
numberStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
subtotalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"F3F4F6"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "9CA3AF", Style: 1},
{Type: "top", Color: "9CA3AF", Style: 1},
{Type: "bottom", Color: "9CA3AF", Style: 1},
{Type: "right", Color: "9CA3AF", Style: 1},
},
})
if err != nil {
return 0, 0, 0, 0, 0, 0, 0, 0, 0, err
}
return titleStyle, metaStyle, monthStyle, weekStyle, dayHeaderStyle, farmStyle, textStyle, numberStyle, subtotalStyle, nil
}
func applyColumnWidths(file *excelize.File, sheet string, maxWeeks int) error {
if err := file.SetColWidth(sheet, "A", "A", 28); err != nil {
return err
}
for col := 2; col <= 1+(maxWeeks*7); col++ {
colName, err := excelize.ColumnNumberToName(col)
if err != nil {
return err
}
if err := file.SetColWidth(sheet, colName, colName, 6); err != nil {
return err
}
}
totalCol, err := excelize.ColumnNumberToName(1 + (maxWeeks * 7) + 1)
if err != nil {
return err
}
return file.SetColWidth(sheet, totalCol, totalCol, 10)
}
func renderMonthHeader(file *excelize.File, sheet string, startRow int, block monthBlock, monthLastCol string, monthStyle, weekStyle, dayHeaderStyle int) error {
if err := file.MergeCell(sheet, "A"+fmt.Sprint(startRow), monthLastCol+fmt.Sprint(startRow)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "A"+fmt.Sprint(startRow), block.Start.Format("January 2006")); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+fmt.Sprint(startRow), monthLastCol+fmt.Sprint(startRow), monthStyle); err != nil {
return err
}
if err := file.MergeCell(sheet, "A"+fmt.Sprint(startRow+1), "A"+fmt.Sprint(startRow+3)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "A"+fmt.Sprint(startRow+1), "Kandang"); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+fmt.Sprint(startRow+1), "A"+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil {
return err
}
totalColIndex := 1 + (block.Weeks * 7) + 1
totalColName, err := excelize.ColumnNumberToName(totalColIndex)
if err != nil {
return err
}
if err := file.MergeCell(sheet, totalColName+fmt.Sprint(startRow+1), totalColName+fmt.Sprint(startRow+3)); err != nil {
return err
}
if err := file.SetCellValue(sheet, totalColName+fmt.Sprint(startRow+1), "Total"); err != nil {
return err
}
if err := file.SetCellStyle(sheet, totalColName+fmt.Sprint(startRow+1), totalColName+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil {
return err
}
weekdayNames := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
for week := 0; week < block.Weeks; week++ {
startCol := 2 + (week * 7)
endCol := startCol + 6
startColName, err := excelize.ColumnNumberToName(startCol)
if err != nil {
return err
}
endColName, err := excelize.ColumnNumberToName(endCol)
if err != nil {
return err
}
if err := file.MergeCell(sheet, startColName+fmt.Sprint(startRow+1), endColName+fmt.Sprint(startRow+1)); err != nil {
return err
}
if err := file.SetCellValue(sheet, startColName+fmt.Sprint(startRow+1), fmt.Sprintf("Week %d", week+1)); err != nil {
return err
}
if err := file.SetCellStyle(sheet, startColName+fmt.Sprint(startRow+1), endColName+fmt.Sprint(startRow+1), weekStyle); err != nil {
return err
}
for weekday := 0; weekday < 7; weekday++ {
colIndex := startCol + weekday
colName, err := excelize.ColumnNumberToName(colIndex)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+fmt.Sprint(startRow+2), weekdayNames[weekday]); err != nil {
return err
}
if err := file.SetCellStyle(sheet, colName+fmt.Sprint(startRow+2), colName+fmt.Sprint(startRow+2), dayHeaderStyle); err != nil {
return err
}
}
}
daysInMonth := time.Date(block.Start.Year(), block.Start.Month()+1, 0, 0, 0, 0, 0, block.Start.Location()).Day()
for day := 1; day <= daysInMonth; day++ {
date := time.Date(block.Start.Year(), block.Start.Month(), day, 0, 0, 0, 0, block.Start.Location())
colIndex := dayColumnIndex(block, date)
colName, err := excelize.ColumnNumberToName(colIndex)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+fmt.Sprint(startRow+3), day); err != nil {
return err
}
if err := file.SetCellStyle(sheet, colName+fmt.Sprint(startRow+3), colName+fmt.Sprint(startRow+3), dayHeaderStyle); err != nil {
return err
}
}
return nil
}
func groupRows(rows []Row) map[string]map[string]map[string]map[string]int {
grouped := make(map[string]map[string]map[string]map[string]int)
for _, row := range rows {
monthKey := row.ActivityDate.Format("2006-01")
if _, exists := grouped[monthKey]; !exists {
grouped[monthKey] = make(map[string]map[string]map[string]int)
}
farmName := strings.TrimSpace(row.FarmName)
if farmName == "" {
farmName = "Unknown Farm"
}
if _, exists := grouped[monthKey][farmName]; !exists {
grouped[monthKey][farmName] = make(map[string]map[string]int)
}
kandangName := strings.TrimSpace(row.KandangName)
if kandangName == "" {
kandangName = UnassignedKandangName
}
if _, exists := grouped[monthKey][farmName][kandangName]; !exists {
grouped[monthKey][farmName][kandangName] = make(map[string]int)
}
dayKey := row.ActivityDate.Format("2006-01-02")
grouped[monthKey][farmName][kandangName][dayKey] += row.Count
}
return grouped
}
func monthBlocksBetween(startDate, endDate time.Time) []monthBlock {
location := startDate.Location()
current := time.Date(startDate.Year(), startDate.Month(), 1, 0, 0, 0, 0, location)
last := time.Date(endDate.Year(), endDate.Month(), 1, 0, 0, 0, 0, location)
blocks := make([]monthBlock, 0)
for !current.After(last) {
blocks = append(blocks, monthBlock{
Start: current,
Weeks: monthWeeks(current),
})
current = current.AddDate(0, 1, 0)
}
return blocks
}
func monthWeeks(monthStart time.Time) int {
daysInMonth := time.Date(monthStart.Year(), monthStart.Month()+1, 0, 0, 0, 0, 0, monthStart.Location()).Day()
offset := mondayIndex(monthStart.Weekday())
totalSlots := offset + daysInMonth
weeks := totalSlots / 7
if totalSlots%7 != 0 {
weeks++
}
if weeks < 4 {
return 4
}
return weeks
}
func dayColumnIndex(block monthBlock, date time.Time) int {
day := date.Day()
offset := mondayIndex(block.Start.Weekday())
position := offset + (day - 1)
return 2 + position
}
func mondayIndex(weekday time.Weekday) int {
switch weekday {
case time.Sunday:
return 6
default:
return int(weekday) - 1
}
}
func sortedKeys[V any](input map[string]V) []string {
keys := make([]string, 0, len(input))
for key := range input {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func prevColumn(col string) string {
index, err := excelize.ColumnNameToNumber(col)
if err != nil || index <= 1 {
return col
}
result, err := excelize.ColumnNumberToName(index - 1)
if err != nil {
return col
}
return result
}
@@ -0,0 +1,126 @@
package exportprogress
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
func TestParseQuery(t *testing.T) {
app := fiber.New()
app.Get("/", func(c *fiber.Ctx) error {
query, err := ParseQuery(c)
if err != nil {
return err
}
return c.JSON(fiber.Map{
"start": query.StartDateRaw,
"end": query.EndDateRaw,
})
})
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/?export=excel&type=progress&start_date=2026-06-01&end_date=2026-07-15", nil))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var payload map[string]string
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("failed decoding payload: %v", err)
}
if payload["start"] != "2026-06-01" || payload["end"] != "2026-07-15" {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestParseQueryInvalid(t *testing.T) {
app := fiber.New()
app.Get("/", func(c *fiber.Ctx) error {
_, err := ParseQuery(c)
return err
})
cases := []string{
"/?export=excel&type=progress",
"/?export=excel&type=progress&start_date=2026-06-01&end_date=bad",
"/?export=excel&type=progress&start_date=2026-07-01&end_date=2026-06-01",
}
for _, target := range cases {
resp, err := app.Test(httptest.NewRequest(http.MethodGet, target, nil))
if err != nil {
t.Fatalf("request failed for %s: %v", target, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for %s, got %d", target, resp.StatusCode)
}
}
}
func TestBuildWorkbook(t *testing.T) {
location, err := time.LoadLocation(jakartaTZ)
if err != nil {
t.Fatalf("failed loading location: %v", err)
}
query := &Query{
StartDate: time.Date(2026, 6, 1, 0, 0, 0, 0, location),
EndDate: time.Date(2026, 7, 31, 0, 0, 0, 0, location),
StartDateRaw: "2026-06-01",
EndDateRaw: "2026-07-31",
}
rows := []Row{
{Module: "Expenses", FarmName: "Farm A", KandangName: "Kandang 1", ActivityDate: time.Date(2026, 6, 1, 0, 0, 0, 0, location), Count: 3},
{Module: "Expenses", FarmName: "Farm A", KandangName: "Kandang 1", ActivityDate: time.Date(2026, 7, 15, 0, 0, 0, 0, location), Count: 2},
}
content, err := BuildWorkbook("Expenses", query, rows)
if err != nil {
t.Fatalf("BuildWorkbook failed: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed opening workbook: %v", err)
}
defer file.Close()
if got := file.GetSheetName(file.GetActiveSheetIndex()); got != "Expenses" {
t.Fatalf("unexpected sheet name: %s", got)
}
title, err := file.GetCellValue("Expenses", "A1")
if err != nil {
t.Fatalf("failed reading title: %v", err)
}
if title != "Expenses" {
t.Fatalf("unexpected title: %s", title)
}
monthTitle, err := file.GetCellValue("Expenses", "A4")
if err != nil {
t.Fatalf("failed reading first month title: %v", err)
}
if monthTitle != "June 2026" {
t.Fatalf("unexpected first month title: %s", monthTitle)
}
firstCount, err := file.GetCellValue("Expenses", "B9")
if err != nil {
t.Fatalf("failed reading representative count cell: %v", err)
}
if firstCount != "3" {
t.Fatalf("unexpected representative count: %s", firstCount)
}
}
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"errors"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -23,6 +24,7 @@ type HppCostRepository interface {
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error)
} }
type HppRepositoryImpl struct { type HppRepositoryImpl struct {
@@ -48,12 +50,32 @@ func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, proje
} }
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("project_chickins AS pc"). Table("project_chickins AS pc").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). Select(`
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). COALESCE(SUM(sa.qty * CASE
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableAdjustment,
).
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
usableProjectChickin,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeTraceChickin,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
@@ -85,7 +107,7 @@ func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockK
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock). Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("f.name = ?", utils.FlagEkspedisi). // Where("f.name = ?", utils.FlagEkspedisi).
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -100,16 +122,36 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
date = &now date = &now
} }
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
var total float64 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("recordings AS r"). Table("recordings AS r").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). Select(`
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableAdjustment,
).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins(
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). usableRecordingStock,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan). Where("f.name = ?", utils.FlagPakan).
Scan(&total).Error Scan(&total).Error
@@ -132,16 +174,35 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
utils.FlagVitamin, utils.FlagVitamin,
utils.FlagKimia, utils.FlagKimia,
} }
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
var total float64 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("recordings AS r"). Table("recordings AS r").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). Select(`
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableAdjustment,
).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins(
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). usableRecordingStock,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags). Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error Scan(&total).Error
@@ -169,22 +230,28 @@ func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlock
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) { func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String() stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableTransferIn := fifo.StockableKeyStockTransferIn.String() stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String() usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("project_chickins AS pc"). Table("project_chickins AS pc").
Select(` Select(`
COALESCE(SUM(sa.qty * CASE COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
ELSE 0 WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
END), 0)`, ELSE 0
stockablePurchase, stockableTransferIn). END), 0)`,
stockablePurchase,
stockableTransferIn,
stockableAdjustment,
).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id"). Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId). Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
@@ -215,6 +282,33 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang
return 0, 0, err return 0, 0, err
} }
var adjustmentTotalWeight float64
adjustmentSubQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select("DISTINCT ast.id AS adjustment_id, ast.price AS price").
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id").
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = std.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyStockTransferOut.String(),
fifo.StockableKeyAdjustmentIn.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND ast.product_warehouse_id = std.source_product_warehouse_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date)
err = r.db.WithContext(ctx).
Table("(?) AS adjustment_sources", adjustmentSubQuery).
Select("COALESCE(SUM(adjustment_sources.price), 0)").
Scan(&adjustmentTotalWeight).Error
if err != nil {
return 0, 0, err
}
totals.TotalWeightKg += adjustmentTotalWeight
return totals.TotalPieces, totals.TotalWeightKg, nil return totals.TotalPieces, totals.TotalWeightKg, nil
} }
@@ -311,3 +405,25 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec
return summary.ProjectFlockID, summary.TotalQty, nil return summary.ProjectFlockID, summary.TotalQty, nil
} }
func (r *HppRepositoryImpl) GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error) {
type row struct {
TotalCost float64
}
var selected row
err := r.db.WithContext(ctx).
Table("farm_depreciation_manual_inputs").
Select("total_cost").
Where("project_flock_id = ?", projectFlockId).
Limit(1).
Take(&selected).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return selected.TotalCost, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,390 @@
package repository
import (
"context"
"fmt"
"math"
"testing"
"time"
"github.com/glebarez/sqlite"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
func TestHppV2RepositoryGetEggProduksiIncludesTransferredAdjustmentStock(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
mustExecHppV2(t, db,
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime) VALUES (1, 101, '2026-04-19 10:00:00')`,
`INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 401, 80, 8, 101)`,
`INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00')`,
`INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 301, 401)`,
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose) VALUES (1, 'STOCK_TRANSFER_OUT', 1, 'ADJUSTMENT_IN', 501, 'ACTIVE', 'CONSUME')`,
`INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, price, created_at) VALUES (501, 301, 20, 2.5, '2026-04-18 07:30:00')`,
)
repo := &HppV2RepositoryImpl{db: db}
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
totalPieces, totalWeightKg, err := repo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &endDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, totalPieces, 100)
assertFloatEquals(t, totalWeightKg, 10.5)
}
func TestHppV2RepositoryGetEggTerjualUsesEndDateForSameDayFarmSales(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
mustExecHppV2(t, db,
`INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`,
`INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`,
`INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10)`,
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL)`,
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`,
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES (1, 101, '2026-04-19 08:00:00', NULL), (2, 102, '2026-04-19 09:00:00', NULL)`,
`INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 301, 60, 6, 101), (2, 2, 301, 40, 4, 102)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 50, 5, '2026-04-19 12:00:00')`,
)
repo := &HppV2RepositoryImpl{db: db}
startDate := mustJakartaTime(t, "2026-04-19 00:00:00")
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, totalPieces, 30)
assertFloatEquals(t, totalWeightKg, 3)
}
func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
mustExecHppV2(t, db,
`INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`,
`INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`,
`INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10), (211, 'KANDANG', 10), (212, 'KANDANG', 10)`,
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL), (311, 211, 900, 101), (312, 212, 900, 102)`,
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`,
`INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00'), (2, '2026-04-18 08:15:00')`,
`INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 311, 301), (2, 2, 312, 301)`,
`INSERT INTO adjustment_stocks (id, product_warehouse_id, usage_qty, price, function_code, transaction_type, created_at) VALUES
(801, 311, 70, 7, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:00:00'),
(802, 312, 30, 3, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:30:00')`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 20, 2, '2026-04-19 12:00:00')`,
)
repo := &HppV2RepositoryImpl{db: db}
startDate := mustJakartaTime(t, "2026-04-19 00:00:00")
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, totalPieces, 14)
assertFloatEquals(t, totalWeightKg, 1.4)
}
func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
approvalType := utils.ApprovalWorkflowTransferToLaying.String()
mustExecHppV2(t, db,
`INSERT INTO project_flock_kandangs (id, kandang_id, project_flock_id) VALUES
(101, 1, 1),
(102, 2, 1),
(103, 3, 1),
(104, 4, 1),
(105, 5, 1),
(201, 6, 2)`,
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES
(1, 101, '2026-04-10 08:00:00', NULL),
(2, 101, '2026-04-10 08:05:00', NULL),
(3, 101, '2026-04-10 08:10:00', NULL),
(4, 102, '2026-04-10 08:15:00', NULL),
(5, 102, '2026-04-10 08:20:00', NULL),
(6, 103, '2026-04-12 08:00:00', NULL),
(7, 103, '2026-04-12 08:05:00', NULL),
(8, 104, '2026-04-12 08:10:00', NULL),
(9, 104, '2026-04-12 08:15:00', NULL),
(10, 105, '2026-04-12 08:20:00', NULL),
(11, 105, '2026-04-12 08:25:00', NULL)`,
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES
(501, 201, 10, NULL),
(502, 201, 10, NULL),
(503, 201, 10, NULL),
(504, 201, 10, NULL),
(505, 201, 10, NULL),
(506, 201, 10, NULL),
(507, 201, 10, NULL),
(508, 201, 10, NULL),
(509, 201, 10, NULL),
(510, 201, 10, NULL),
(511, 201, 10, NULL)`,
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES
(10, 'products', 10, 'PAKAN')`,
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, project_flock_kandang_id) VALUES
(101, 1, 501, NULL),
(102, 2, 502, 201),
(103, 3, 503, 101),
(104, 4, 504, NULL),
(105, 5, 505, 201),
(106, 6, 506, NULL),
(107, 7, 507, 201),
(108, 8, 508, NULL),
(109, 9, 509, 201),
(110, 10, 510, NULL),
(111, 11, 511, 201)`,
`INSERT INTO purchase_items (id, product_id, price) VALUES
(601, 10, 100),
(602, 10, 110),
(603, 10, 120),
(604, 10, 130),
(605, 10, 140),
(606, 10, 150),
(607, 10, 160),
(608, 10, 170),
(609, 10, 180),
(610, 10, 190),
(611, 10, 200)`,
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose, qty) VALUES
(9001, 'RECORDING_STOCK', 101, 'PURCHASE_ITEMS', 601, 'ACTIVE', 'CONSUME', 2),
(9002, 'RECORDING_STOCK', 102, 'PURCHASE_ITEMS', 602, 'ACTIVE', 'CONSUME', 1),
(9003, 'RECORDING_STOCK', 103, 'PURCHASE_ITEMS', 603, 'ACTIVE', 'CONSUME', 1),
(9004, 'RECORDING_STOCK', 104, 'PURCHASE_ITEMS', 604, 'ACTIVE', 'CONSUME', 1),
(9005, 'RECORDING_STOCK', 105, 'PURCHASE_ITEMS', 605, 'ACTIVE', 'CONSUME', 1),
(9006, 'RECORDING_STOCK', 106, 'PURCHASE_ITEMS', 606, 'ACTIVE', 'CONSUME', 1),
(9007, 'RECORDING_STOCK', 107, 'PURCHASE_ITEMS', 607, 'ACTIVE', 'CONSUME', 1),
(9008, 'RECORDING_STOCK', 108, 'PURCHASE_ITEMS', 608, 'ACTIVE', 'CONSUME', 1),
(9009, 'RECORDING_STOCK', 109, 'PURCHASE_ITEMS', 609, 'ACTIVE', 'CONSUME', 1),
(9010, 'RECORDING_STOCK', 110, 'PURCHASE_ITEMS', 610, 'ACTIVE', 'CONSUME', 1),
(9011, 'RECORDING_STOCK', 111, 'PURCHASE_ITEMS', 611, 'ACTIVE', 'CONSUME', 1)`,
`INSERT INTO laying_transfers (id, transfer_date, effective_move_date, economic_cutoff_date, executed_at, deleted_at) VALUES
(1001, '2026-04-04', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL),
(1002, '2026-05-01', '2026-05-01', NULL, '2026-05-01 00:00:00', NULL),
(1003, '2026-04-03', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL),
(1004, '2026-04-03', '2026-04-05', NULL, NULL, NULL)`,
`INSERT INTO laying_transfer_targets (id, laying_transfer_id, target_project_flock_kandang_id, deleted_at) VALUES
(2001, 1001, 101, NULL),
(2002, 1002, 103, NULL),
(2003, 1003, 104, NULL),
(2004, 1004, 105, NULL)`,
fmt.Sprintf(`INSERT INTO approvals (id, approvable_type, approvable_id, action) VALUES
(3001, '%s', 1001, 'APPROVED'),
(3002, '%s', 1002, 'APPROVED'),
(3003, '%s', 1003, 'APPROVED'),
(3004, '%s', 1003, 'REJECTED'),
(3005, '%s', 1004, 'APPROVED')`,
approvalType, approvalType, approvalType, approvalType, approvalType),
)
repo := &HppV2RepositoryImpl{db: db}
periodDate := mustJakartaTime(t, "2026-04-30 00:00:00")
total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, total, 750)
earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59")
earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, earlyTotal, 240)
}
func setupHppV2RepositoryTestDB(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)
}
mustExecHppV2(t, db,
`CREATE TABLE recordings (
id INTEGER PRIMARY KEY,
project_flock_kandangs_id INTEGER NULL,
record_datetime DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE recording_stocks (
id INTEGER PRIMARY KEY,
recording_id INTEGER NULL,
product_warehouse_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE recording_eggs (
id INTEGER PRIMARY KEY,
recording_id INTEGER NULL,
product_warehouse_id INTEGER NULL,
qty NUMERIC(15,3) NULL,
weight NUMERIC(15,3) NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE stock_transfers (
id INTEGER PRIMARY KEY,
transfer_date DATETIME NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY,
stock_transfer_id INTEGER NULL,
source_product_warehouse_id INTEGER NULL,
dest_product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
usable_type TEXT NULL,
usable_id INTEGER NULL,
stockable_type TEXT NULL,
stockable_id INTEGER NULL,
status TEXT NULL,
allocation_purpose TEXT NULL,
qty NUMERIC(15,3) NULL
)`,
`CREATE TABLE adjustment_stocks (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NULL,
total_qty NUMERIC(15,3) NULL,
usage_qty NUMERIC(15,3) NULL,
price NUMERIC(15,3) NULL,
grand_total NUMERIC(15,3) NULL,
function_code TEXT NULL,
transaction_type TEXT NULL,
created_at DATETIME NULL
)`,
`CREATE TABLE kandangs (
id INTEGER PRIMARY KEY,
location_id INTEGER NULL
)`,
`CREATE TABLE project_flock_kandangs (
id INTEGER PRIMARY KEY,
kandang_id INTEGER NULL,
project_flock_id INTEGER NULL
)`,
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY,
type TEXT NULL,
location_id INTEGER NULL
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
warehouse_id INTEGER NULL,
product_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE marketing_products (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE purchase_items (
id INTEGER PRIMARY KEY,
product_id INTEGER NULL,
price NUMERIC(15,3) NULL
)`,
`CREATE TABLE marketing_delivery_products (
id INTEGER PRIMARY KEY,
marketing_product_id INTEGER NULL,
usage_qty NUMERIC(15,3) NULL,
total_weight NUMERIC(15,3) NULL,
delivery_date DATETIME NULL
)`,
`CREATE TABLE flags (
id INTEGER PRIMARY KEY,
flagable_type TEXT NULL,
flagable_id INTEGER NULL,
name TEXT NULL
)`,
`CREATE TABLE laying_transfers (
id INTEGER PRIMARY KEY,
transfer_date DATETIME NULL,
effective_move_date DATETIME NULL,
economic_cutoff_date DATETIME NULL,
executed_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE laying_transfer_targets (
id INTEGER PRIMARY KEY,
laying_transfer_id INTEGER NULL,
target_project_flock_kandang_id INTEGER NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE approvals (
id INTEGER PRIMARY KEY,
approvable_type TEXT NULL,
approvable_id INTEGER NULL,
action TEXT NULL
)`,
)
return db
}
func mustExecHppV2(t *testing.T, db *gorm.DB, statements ...string) {
t.Helper()
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("failed executing statement %q: %v", statement, err)
}
}
}
func mustJakartaTime(t *testing.T, raw string) time.Time {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading timezone: %v", err)
}
value, err := time.ParseInLocation("2006-01-02 15:04:05", raw, location)
if err != nil {
t.Fatalf("failed parsing time %q: %v", raw, err)
}
return value
}
func assertFloatEquals(t *testing.T, got float64, want float64) {
t.Helper()
if math.Abs(got-want) > 0.000001 {
t.Fatalf("expected %.6f, got %.6f", want, got)
}
}
func TestHppV2RepositoryConstantsStayAlignedWithProductionQueries(t *testing.T) {
if fifo.UsableKeyStockTransferOut.String() != "STOCK_TRANSFER_OUT" {
t.Fatalf("unexpected stock transfer usable key: %s", fifo.UsableKeyStockTransferOut.String())
}
if fifo.StockableKeyAdjustmentIn.String() != "ADJUSTMENT_IN" {
t.Fatalf("unexpected adjustment stockable key: %s", fifo.StockableKeyAdjustmentIn.String())
}
if entity.StockAllocationStatusActive != "ACTIVE" {
t.Fatalf("unexpected active stock allocation status: %s", entity.StockAllocationStatusActive)
}
if entity.StockAllocationPurposeConsume != "CONSUME" {
t.Fatalf("unexpected consume stock allocation purpose: %s", entity.StockAllocationPurposeConsume)
}
if string(utils.AdjustmentTransactionSubtypeRecordingEggIn) != "RECORDING_EGG_IN" {
t.Fatalf("unexpected adjustment function code: %s", utils.AdjustmentTransactionSubtypeRecordingEggIn)
}
if string(utils.AdjustmentTransactionTypeRecording) != "RECORDING" {
t.Fatalf("unexpected adjustment transaction type: %s", utils.AdjustmentTransactionTypeRecording)
}
}
@@ -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 (
"strings"
"time"
)
const (
depreciationStartAgeDayCloseHouse = 155
depreciationStartAgeDayOpenHouse = 176
)
func NormalizeDepreciationHouseType(raw string) string {
return strings.TrimSpace(strings.ToLower(raw))
}
func DepreciationStartAgeDay(houseType string) int {
switch NormalizeDepreciationHouseType(houseType) {
case "close_house":
return depreciationStartAgeDayCloseHouse
case "open_house":
return depreciationStartAgeDayOpenHouse
default:
return 0
}
}
func FlockAgeDay(originDate time.Time, periodDate time.Time) int {
origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, originDate.Location())
period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location())
if period.Before(origin) {
return 0
}
return int(period.Sub(origin).Hours()/24) + 1
}
func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int {
ageDay := FlockAgeDay(originDate, periodDate)
startAgeDay := DepreciationStartAgeDay(houseType)
if ageDay <= 0 || startAgeDay <= 0 || ageDay < startAgeDay {
return 0
}
return ageDay - startAgeDay + 1
}
func CalculateDepreciationAtDayN(
initialPulletCost float64,
dayN int,
houseType string,
percentByHouseType map[string]map[int]float64,
) (float64, float64, float64) {
return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, percentByHouseType)
}
func CalculateDepreciationFromDayRange(
initialPulletCost float64,
startDay int,
endDay int,
houseType string,
percentByHouseType map[string]map[int]float64,
) (float64, float64, float64) {
if initialPulletCost <= 0 || endDay <= 0 {
return 0, 0, 0
}
if startDay <= 0 {
startDay = 1
}
if endDay < startDay {
return 0, 0, 0
}
normalizedHouseType := NormalizeDepreciationHouseType(houseType)
housePercent, exists := percentByHouseType[normalizedHouseType]
if !exists {
return 0, 0, 0
}
current := initialPulletCost
pulletCostDayN := 0.0
depreciationValue := 0.0
depreciationPercent := 0.0
for day := startDay; day <= endDay; day++ {
pct := housePercent[day]
dep := current * (pct / 100)
if day == endDay {
pulletCostDayN = current
depreciationValue = dep
depreciationPercent = pct
}
current -= dep
if current < 0 {
current = 0
}
}
return pulletCostDayN, depreciationValue, depreciationPercent
}
func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 {
if totalPulletCostDayN <= 0 {
return 0
}
return (totalDepreciationValue / totalPulletCostDayN) * 100
}
@@ -0,0 +1,81 @@
package service
import (
"testing"
"time"
)
func TestDepreciationScheduleDay_UsesHouseTypeOffsets(t *testing.T) {
openOrigin := mustDepreciationDate(t, "2026-01-01")
if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-24"), "open_house"); got != 0 {
t.Fatalf("expected open house day before start to be 0, got %d", got)
}
if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-25"), "open_house"); got != 1 {
t.Fatalf("expected open house start day to map to schedule day 1, got %d", got)
}
closeOrigin := mustDepreciationDate(t, "2026-01-01")
if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-03"), "close_house"); got != 0 {
t.Fatalf("expected close house day before start to be 0, got %d", got)
}
if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-04"), "close_house"); got != 1 {
t.Fatalf("expected close house start day to map to schedule day 1, got %d", got)
}
}
func TestCalculateDepreciationAtDayN_UsesRemainingBasisRecursively(t *testing.T) {
percentByHouseType := map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
},
}
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN(1000, 2, "close_house", percentByHouseType)
if pulletCostDayN != 900 {
t.Fatalf("expected remaining basis entering day 2 to be 900, got %v", pulletCostDayN)
}
if depreciationValue != 180 {
t.Fatalf("expected day 2 depreciation to be 180, got %v", depreciationValue)
}
if depreciationPercent != 20 {
t.Fatalf("expected day 2 depreciation percent to be 20, got %v", depreciationPercent)
}
}
func TestCalculateDepreciationFromDayRange_StartsFromProvidedScheduleDay(t *testing.T) {
percentByHouseType := map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
3: 5,
},
}
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange(1000, 2, 3, "close_house", percentByHouseType)
if pulletCostDayN != 800 {
t.Fatalf("expected remaining basis entering day 3 to be 800, got %v", pulletCostDayN)
}
if depreciationValue != 40 {
t.Fatalf("expected day 3 depreciation to be 40, got %v", depreciationValue)
}
if depreciationPercent != 5 {
t.Fatalf("expected day 3 depreciation percent to be 5, got %v", depreciationPercent)
}
}
func mustDepreciationDate(t *testing.T, raw string) time.Time {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading timezone: %v", err)
}
value, err := time.ParseInLocation("2006-01-02", raw, location)
if err != nil {
t.Fatalf("failed parsing date %q: %v", raw, err)
}
return value
}
+121 -13
View File
@@ -46,6 +46,7 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -54,16 +55,21 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay) depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer) totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
if err != nil {
return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) return nil, err
}
return result, nil
} }
func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) { func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) {
@@ -73,40 +79,48 @@ func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, d
} }
if s.hppRepo == nil { if s.hppRepo == nil {
return 0, nil return 0, nil
} }
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs) docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs)
if err != nil { if err != nil {
return 0, err return 0, err
} }
budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID) budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs) expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs)
if err != nil { if err != nil {
return 0, err return 0, err
} }
feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date) feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date)
if err != nil { if err != nil {
return 0, err return 0, err
} }
ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date) ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil total := docCost + budgetCost + expedisionCost + feedCost + ovkCost
return total, nil
} }
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) { func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) {
@@ -117,30 +131,40 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate
costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId) costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
if err != nil { if err != nil {
return 0, err return 0, err
} }
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil { if err != nil {
return 0, err return 0, err
} }
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate) costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil { if err != nil {
return 0, err return 0, err
} }
costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId}) costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId})
if err != nil { if err != nil {
return 0, err return 0, err
} }
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate) costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil // fmt.Println(costBudget, costExpedision, costOvk, costFeed, costPullet, depresiasiTransfer)
// depresiasiTransfer = 0
total := depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget
return total, nil
} }
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) { func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
@@ -150,48 +174,57 @@ func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate
// } // }
if s.hppRepo == nil { if s.hppRepo == nil {
return 0, nil return 0, nil
} }
projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId) projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
if err != nil { if err != nil {
return 0, err return 0, err
} }
projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId) projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId)
if err != nil { if err != nil {
return 0, err return 0, err
} }
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate) eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
if err != nil { if err != nil {
return 0, err return 0, err
} }
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil { if err != nil {
return 0, err return 0, err
} }
totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId) totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId)
if err != nil { if err != nil {
return 0, err return 0, err
} }
if eggProduksiPiecesFlock == 0 { if eggProduksiPiecesFlock == 0 {
return 0, nil return 0, nil
} }
return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil result := (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock
return result, nil
} }
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) { func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
// if endDate == nil { if endDate == nil {
// now := time.Now() now := time.Now()
// endDate = &now endDate = &now
// } }
if s.hppRepo == nil { if s.hppRepo == nil {
return 0, nil return 0, nil
} }
@@ -199,6 +232,13 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *
if err != nil { if err != nil {
return 0, err return 0, err
} }
if sourceProjectFlockID == 0 || transferTotalQty <= 0 {
result, fallbackErr := s.getManualDepresiasiTransferFallback(projectFlockKandangId)
if fallbackErr != nil {
return 0, fallbackErr
}
return result, nil
}
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil { if err != nil {
@@ -218,22 +258,81 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *
return 0, err return 0, err
} }
return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil result := (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing
return result, nil
}
func (s *hppService) getManualDepresiasiTransferFallback(projectFlockKandangId uint) (float64, error) {
projectFlockID, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
if projectFlockID == 0 {
return 0, nil
}
manualCost, err := s.hppRepo.GetManualDepreciationCostByProjectFlockID(context.Background(), projectFlockID)
if err != nil {
return 0, err
}
if manualCost <= 0 {
return 0, nil
}
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockID)
if err != nil {
return 0, err
}
if len(kandangIDs) == 0 {
return 0, nil
}
totalUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
if totalUsageQty <= 0 {
return 0, nil
}
kandangUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return 0, err
}
if kandangUsageQty <= 0 {
return 0, nil
}
result := manualCost * (kandangUsageQty / totalUsageQty)
return result, nil
} }
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) { func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
if s.hppRepo == nil { if s.hppRepo == nil {
return &HppCostResponse{}, nil return &HppCostResponse{}, nil
} }
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate) estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate) realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -261,12 +360,21 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
} }
return &HppCostResponse{ result := &HppCostResponse{
Estimation: estimation, Estimation: estimation,
Real: real, Real: real,
}, nil }
return result, nil
} }
func roundToTwoDecimals(value float64) float64 { func roundToTwoDecimals(value float64) float64 {
return math.Round(value*100) / 100 result := math.Round(value*100) / 100
return result
}
func formatTimePtr(value *time.Time) string {
if value == nil {
return "<nil>"
}
return value.Format(time.RFC3339)
} }
@@ -0,0 +1,61 @@
package service
type HppV2DateWindow struct {
Start string `json:"start"`
End string `json:"end"`
}
type HppV2Proration struct {
Basis string `json:"basis"`
Numerator float64 `json:"numerator"`
Denominator float64 `json:"denominator"`
Ratio float64 `json:"ratio"`
}
type HppV2Reference struct {
Type string `json:"type"`
ID uint `json:"id"`
StockableType string `json:"stockable_type,omitempty"`
ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"`
ProductID uint `json:"product_id,omitempty"`
ProductName string `json:"product_name,omitempty"`
Date string `json:"date,omitempty"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
Total float64 `json:"total"`
AppliedTotal float64 `json:"applied_total"`
}
type HppV2ComponentPart struct {
Code string `json:"code"`
Title string `json:"title"`
Scopes []string `json:"scopes,omitempty"`
Total float64 `json:"total"`
Proration *HppV2Proration `json:"proration,omitempty"`
Details map[string]any `json:"details,omitempty"`
References []HppV2Reference `json:"references,omitempty"`
}
type HppV2Component struct {
Code string `json:"code"`
Title string `json:"title"`
Scopes []string `json:"scopes,omitempty"`
Total float64 `json:"total"`
Parts []HppV2ComponentPart `json:"parts"`
}
type HppV2Breakdown struct {
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
ProjectFlockID uint `json:"project_flock_id"`
ProjectFlockCategory string `json:"project_flock_category,omitempty"`
HouseType string `json:"house_type,omitempty"`
KandangID uint `json:"kandang_id,omitempty"`
KandangName string `json:"kandang_name,omitempty"`
LocationID uint `json:"location_id,omitempty"`
PeriodDate string `json:"period_date"`
Window HppV2DateWindow `json:"window"`
TotalPulletCost float64 `json:"total_pullet_cost"`
TotalProductionCost float64 `json:"total_production_cost"`
Components []HppV2Component `json:"components"`
Hpp HppCostResponse `json:"hpp"`
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,877 @@
package service
import (
"context"
"fmt"
"sort"
"strings"
"testing"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
type hppV2RepoStub struct {
contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext
pfkIDsByProject map[uint][]uint
latestTransferByPFK map[uint]*commonRepo.HppV2LatestTransferInputRow
manualInputByProject map[uint]*commonRepo.HppV2ManualDepreciationInputRow
snapshotByProjectKey map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow
chickInDateByProject map[uint]*time.Time
depreciationByHouse map[string]map[int]float64
usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow
adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
routeCostByProject map[uint]float64
totalPopulationByKey map[string]float64
transferSummaryByPFK map[uint]struct {
projectFlockID uint
totalQty float64
}
eggProductionByPFK map[uint]struct {
pieces float64
kg float64
}
eggSalesByPFK map[uint]struct {
pieces float64
kg float64
}
}
func (s *hppV2RepoStub) GetProjectFlockKandangContext(_ context.Context, projectFlockKandangId uint) (*commonRepo.HppV2ProjectFlockKandangContext, error) {
row := s.contextByPFK[projectFlockKandangId]
if row == nil {
return nil, fmt.Errorf("pfk %d not found", projectFlockKandangId)
}
return row, nil
}
func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFlockId uint) ([]uint, error) {
return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil
}
func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) (*commonRepo.HppV2LatestTransferInputRow, error) {
return s.latestTransferByPFK[projectFlockKandangId], nil
}
func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) {
return s.manualInputByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(_ context.Context, projectFlockID uint, _ time.Time) (float64, error) {
return s.routeCostByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) {
if s.snapshotByProjectKey == nil {
return nil, nil
}
return s.snapshotByProjectKey[fmt.Sprintf("%d|%s", projectFlockID, periodDate.Format("2006-01-02"))], nil
}
func (s *hppV2RepoStub) GetEarliestChickInDateByProjectFlockID(_ context.Context, projectFlockID uint) (*time.Time, error) {
return s.chickInDateByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) {
result := make(map[string]map[int]float64)
for _, houseType := range houseTypes {
source := s.depreciationByHouse[houseType]
if len(source) == 0 {
continue
}
result[houseType] = make(map[int]float64)
for day, pct := range source {
if day <= maxDay {
result[houseType][day] = pct
}
}
}
return result, nil
}
func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) {
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
}
func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) {
return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
}
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil
}
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil
}
func (s *hppV2RepoStub) ListChickinCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time, excludeTransferToLaying bool) ([]commonRepo.HppV2ChickinCostRow, error) {
return append([]commonRepo.HppV2ChickinCostRow{}, s.chickinRowsByKey[chickinStubKey(projectFlockKandangIDs, flagNames, excludeTransferToLaying)]...), nil
}
func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) {
return 0, nil
}
func (s *hppV2RepoStub) GetTotalPopulation(_ context.Context, projectFlockKandangIDs []uint) (float64, error) {
return s.totalPopulationByKey[stubKey(projectFlockKandangIDs, nil)], nil
}
func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, error) {
totalPieces := 0.0
totalKg := 0.0
for _, projectFlockKandangID := range projectFlockKandangIDs {
row := s.eggProductionByPFK[projectFlockKandangID]
totalPieces += row.pieces
totalKg += row.kg
}
return totalPieces, totalKg, nil
}
func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) {
if len(projectFlockKandangIDs) != 1 {
return 0, 0, nil
}
row := s.eggSalesByPFK[projectFlockKandangIDs[0]]
return row.pieces, row.kg, nil
}
func (s *hppV2RepoStub) GetTransferSourceSummary(_ context.Context, projectFlockKandangId uint) (uint, float64, error) {
row := s.transferSummaryByPFK[projectFlockKandangId]
return row.projectFlockID, row.totalQty, nil
}
func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
10: {
ProjectFlockKandangID: 10,
ProjectFlockID: 2,
ProjectFlockCategory: "LAYING",
KandangID: 100,
KandangName: "Kandang A",
LocationID: 16,
},
},
pfkIDsByProject: map[uint][]uint{
1: {101, 102},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{101, 102}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9001, SourceProductID: 8, SourceProductName: "Pakan Growing", Qty: 100, UnitPrice: 40, TotalCost: 4000},
},
stubKey([]uint{10}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9002, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 50, UnitPrice: 30, TotalCost: 1500},
},
},
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
stubKey([]uint{101, 102}, []string{"PAKAN-CUTOVER"}): {
{AdjustmentID: 8001, ProductID: 11, ProductName: "Pakan Growing Cut-over", Qty: 20, Price: 30, GrandTotal: 600},
},
stubKey([]uint{10}, []string{"PAKAN-CUTOVER"}): {
{AdjustmentID: 8002, ProductID: 12, ProductName: "Pakan Laying Cut-over", Qty: 10, Price: 30, GrandTotal: 300},
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{101, 102}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
10: {projectFlockID: 1, totalQty: 250},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
10: {pieces: 100, kg: 10},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
10: {pieces: 40, kg: 4},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(10, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected breakdown result")
}
if got := result.TotalPulletCost; got != 1150 {
t.Fatalf("expected total pullet cost 1150, got %v", got)
}
if got := result.TotalProductionCost; got != 1800 {
t.Fatalf("expected total production cost 1800, got %v", got)
}
if len(result.Components) != 1 {
t.Fatalf("expected 1 component, got %d", len(result.Components))
}
component := result.Components[0]
if component.Code != "PAKAN" {
t.Fatalf("expected PAKAN component, got %s", component.Code)
}
partTotals := map[string]float64{}
for _, part := range component.Parts {
partTotals[part.Code] = part.Total
}
if partTotals[hppV2PartGrowingNormal] != 1000 {
t.Fatalf("expected growing normal 1000, got %v", partTotals[hppV2PartGrowingNormal])
}
if partTotals[hppV2PartGrowingCutover] != 150 {
t.Fatalf("expected growing cutover 150, got %v", partTotals[hppV2PartGrowingCutover])
}
if partTotals[hppV2PartLayingNormal] != 1500 {
t.Fatalf("expected laying normal 1500, got %v", partTotals[hppV2PartLayingNormal])
}
if partTotals[hppV2PartLayingCutover] != 300 {
t.Fatalf("expected laying cutover 300, got %v", partTotals[hppV2PartLayingCutover])
}
if component.Parts[0].Proration == nil || component.Parts[0].Proration.Ratio != 0.25 {
t.Fatalf("expected growing proration ratio 0.25, got %+v", component.Parts[0].Proration)
}
if result.Hpp.Estimation.HargaKg != 180 {
t.Fatalf("expected estimation harga/kg 180, got %v", result.Hpp.Estimation.HargaKg)
}
if result.Hpp.Real.HargaKg != 450 {
t.Fatalf("expected real harga/kg 450, got %v", result.Hpp.Real.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_ManualCutoverUsesLayingSlicesOnly(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
20: {
ProjectFlockKandangID: 20,
ProjectFlockID: 3,
ProjectFlockCategory: "LAYING",
KandangID: 200,
KandangName: "Kandang B",
LocationID: 17,
},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{20}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9100, SourceProductID: 21, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 10, TotalCost: 200},
},
},
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
stubKey([]uint{20}, []string{"PAKAN-CUTOVER"}): {
{AdjustmentID: 8100, ProductID: 22, ProductName: "Pakan Laying Cut-over", Qty: 30, Price: 10, GrandTotal: 300},
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
20: {pieces: 50, kg: 5},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
20: {pieces: 25, kg: 2.5},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(20, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalProductionCost != 500 {
t.Fatalf("expected total production cost 500, got %v", result.TotalProductionCost)
}
component := result.Components[0]
if len(component.Parts) != 2 {
t.Fatalf("expected 2 laying parts, got %d", len(component.Parts))
}
for _, part := range component.Parts {
if strings.HasPrefix(part.Code, "growing_") {
t.Fatalf("expected no growing parts, got %s", part.Code)
}
}
if result.Hpp.Estimation.HargaKg != 100 {
t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
30: {
ProjectFlockKandangID: 30,
ProjectFlockID: 4,
ProjectFlockCategory: "LAYING",
KandangID: 300,
KandangName: "Kandang C",
LocationID: 18,
},
},
pfkIDsByProject: map[uint][]uint{
5: {301, 302},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{30}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9200, SourceProductID: 31, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 25, TotalCost: 500},
},
stubKey([]uint{301, 302}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): {
{StockableType: "purchase_items", StockableID: 9201, SourceProductID: 32, SourceProductName: "OVK Growing", Qty: 40, UnitPrice: 10, TotalCost: 400},
},
stubKey([]uint{30}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): {
{StockableType: "purchase_items", StockableID: 9202, SourceProductID: 33, SourceProductName: "OVK Laying", Qty: 15, UnitPrice: 10, TotalCost: 150},
},
},
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
stubKey([]uint{301, 302}, []string{"OVK"}): {
{AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100},
},
stubKey([]uint{30}, []string{"OVK"}): {
{AdjustmentID: 8202, ProductID: 35, ProductName: "OVK Laying Cut-over", Qty: 5, Price: 10, GrandTotal: 50},
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{301, 302}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
30: {projectFlockID: 5, totalQty: 500},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
30: {pieces: 120, kg: 12},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
30: {pieces: 60, kg: 6},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(30, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected breakdown result")
}
if len(result.Components) != 2 {
t.Fatalf("expected 2 components, got %d", len(result.Components))
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentPakan] != 500 {
t.Fatalf("expected pakan total 500, got %v", componentTotals[hppV2ComponentPakan])
}
if componentTotals[hppV2ComponentOvk] != 450 {
t.Fatalf("expected ovk total 450, got %v", componentTotals[hppV2ComponentOvk])
}
if result.TotalPulletCost != 250 {
t.Fatalf("expected total pullet cost 250, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 700 {
t.Fatalf("expected total production cost 700, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 58.33 {
t.Fatalf("expected estimation harga/kg 58.33, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_IncludesDocAndDirectPulletChickin(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
35: {
ProjectFlockKandangID: 35,
ProjectFlockID: 8,
ProjectFlockCategory: "LAYING",
KandangID: 350,
KandangName: "Kandang E",
LocationID: 20,
},
},
pfkIDsByProject: map[uint][]uint{
9: {901, 902},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{901, 902}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
35: {projectFlockID: 9, totalQty: 250},
},
chickinRowsByKey: map[string][]commonRepo.HppV2ChickinCostRow{
chickinStubKey([]uint{901, 902}, []string{string(utils.FlagDOC)}, false): {
{ProjectChickinID: 1, ProjectFlockKandangID: 901, ChickInDate: mustTime(t, "2026-04-01"), StockableType: "purchase_items", StockableID: 1001, SourceProductID: 77, SourceProductName: "DOC", Qty: 1000, UnitPrice: 2, TotalCost: 2000},
},
chickinStubKey([]uint{35}, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true): {
{ProjectChickinID: 2, ProjectFlockKandangID: 35, ChickInDate: mustTime(t, "2026-04-15"), StockableType: "purchase_items", StockableID: 1002, SourceProductID: 78, SourceProductName: "Pullet", Qty: 50, UnitPrice: 20, TotalCost: 1000},
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
35: {pieces: 100, kg: 10},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
35: {pieces: 80, kg: 8},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(35, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentDocChickin] != 500 {
t.Fatalf("expected doc chickin total 500, got %v", componentTotals[hppV2ComponentDocChickin])
}
if componentTotals[hppV2ComponentDirectPulletPurchase] != 1000 {
t.Fatalf("expected direct pullet purchase total 1000, got %v", componentTotals[hppV2ComponentDirectPulletPurchase])
}
if result.TotalPulletCost != 500 {
t.Fatalf("expected total pullet cost 500, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 1000 {
t.Fatalf("expected total production cost 1000, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 100 {
t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
40: {
ProjectFlockKandangID: 40,
ProjectFlockID: 6,
ProjectFlockCategory: "LAYING",
KandangID: 400,
KandangName: "Kandang D",
LocationID: 19,
},
},
pfkIDsByProject: map[uint][]uint{
6: {40, 41},
7: {701, 702},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{701, 702}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
40: {projectFlockID: 7, totalQty: 200},
},
expenseRowsByPFKKey: map[string][]commonRepo.HppV2ExpenseCostRow{
expenseStubKey([]uint{701, 702}, false): {
{ExpenseRealizationID: 1, NonstockID: 11, NonstockName: "Growing BOP", Qty: 1, Price: 500, TotalCost: 500, RealizationDate: mustTime(t, "2026-04-10")},
},
expenseStubKey([]uint{40}, false): {
{ExpenseRealizationID: 2, NonstockID: 12, NonstockName: "Laying BOP", Qty: 1, Price: 80, TotalCost: 80, RealizationDate: mustTime(t, "2026-04-19")},
},
expenseStubKey([]uint{701, 702}, true): {
{ExpenseRealizationID: 3, NonstockID: 13, NonstockName: "Growing Expedition", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-11")},
},
expenseStubKey([]uint{40}, true): {
{ExpenseRealizationID: 4, NonstockID: 14, NonstockName: "Laying Expedition", Qty: 1, Price: 40, TotalCost: 40, RealizationDate: mustTime(t, "2026-04-19")},
},
},
expenseRowsByFarmKey: map[string][]commonRepo.HppV2ExpenseCostRow{
expenseFarmKey(7, false): {
{ExpenseRealizationID: 5, NonstockID: 15, NonstockName: "Growing Farm BOP", Qty: 1, Price: 300, TotalCost: 300, RealizationDate: mustTime(t, "2026-04-12")},
},
expenseFarmKey(6, false): {
{ExpenseRealizationID: 6, NonstockID: 16, NonstockName: "Laying Farm BOP", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-19")},
},
expenseFarmKey(7, true): {
{ExpenseRealizationID: 7, NonstockID: 17, NonstockName: "Growing Farm Expedition", Qty: 1, Price: 50, TotalCost: 50, RealizationDate: mustTime(t, "2026-04-12")},
},
expenseFarmKey(6, true): {
{ExpenseRealizationID: 8, NonstockID: 18, NonstockName: "Laying Farm Expedition", Qty: 1, Price: 60, TotalCost: 60, RealizationDate: mustTime(t, "2026-04-19")},
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
40: {pieces: 30, kg: 3},
41: {pieces: 70, kg: 7},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
40: {pieces: 50, kg: 5},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(40, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentBopRegular] != 270 {
t.Fatalf("expected regular BOP total 270, got %v", componentTotals[hppV2ComponentBopRegular])
}
if componentTotals[hppV2ComponentBopEksp] != 88 {
t.Fatalf("expected expedition BOP total 88, got %v", componentTotals[hppV2ComponentBopEksp])
}
if result.TotalPulletCost != 190 {
t.Fatalf("expected total pullet cost 190, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 168 {
t.Fatalf("expected total production cost 168, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 56 {
t.Fatalf("expected estimation harga/kg 56, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_AddsDepreciationForNormalTransfer(t *testing.T) {
sourceChickIn := mustTime(t, "2026-01-01")
reportDate := sourceChickIn.AddDate(0, 0, 154)
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
50: {
ProjectFlockKandangID: 50,
ProjectFlockID: 10,
ProjectFlockCategory: "LAYING",
KandangID: 500,
KandangName: "Kandang F",
LocationID: 21,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
11: {501},
},
latestTransferByPFK: map[uint]*commonRepo.HppV2LatestTransferInputRow{
50: {
ProjectFlockKandangID: 50,
SourceProjectFlockID: 11,
TransferDate: mustTime(t, "2026-05-20"),
TransferQty: 100,
TransferID: 701,
},
},
chickInDateByProject: map[uint]*time.Time{
11: &sourceChickIn,
},
depreciationByHouse: map[string]map[int]float64{
"close_house": {
1: 10,
},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{501}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9301, SourceProductID: 41, SourceProductName: "Pakan Growing", Qty: 25, UnitPrice: 40, TotalCost: 1000},
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{501}, nil): 100,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
50: {projectFlockID: 11, totalQty: 100},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
50: {pieces: 20, kg: 10},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(50, &reportDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalPulletCost != 1000 {
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 100 {
t.Fatalf("expected total production cost 100, got %v", result.TotalProductionCost)
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil {
t.Fatal("expected depreciation component")
}
if depreciation.Total != 100 {
t.Fatalf("expected depreciation total 100, got %v", depreciation.Total)
}
if len(depreciation.Parts) != 1 {
t.Fatalf("expected single depreciation part, got %d", len(depreciation.Parts))
}
if depreciation.Parts[0].Details["schedule_day"] != 1 {
t.Fatalf("expected schedule day 1, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["origin_date"] != "2026-01-01" {
t.Fatalf("expected origin date 2026-01-01, got %+v", depreciation.Parts[0].Details)
}
if result.Hpp.Estimation.HargaKg != 10 {
t.Fatalf("expected estimation harga/kg 10, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_AddsDepreciationForManualCutoverFromCutoverDate(t *testing.T) {
originDate := mustTime(t, "2026-01-01")
cutoverDate := originDate.AddDate(0, 0, 155)
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
60: {
ProjectFlockKandangID: 60,
ProjectFlockID: 12,
ProjectFlockCategory: "LAYING",
KandangID: 600,
KandangName: "Kandang G",
LocationID: 22,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
12: {60},
},
manualInputByProject: map[uint]*commonRepo.HppV2ManualDepreciationInputRow{
12: {
ID: 801,
ProjectFlockID: 12,
TotalCost: 1000,
CutoverDate: cutoverDate,
},
},
chickInDateByProject: map[uint]*time.Time{
12: &originDate,
},
depreciationByHouse: map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{60}, nil): 100,
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
60: {pieces: 20, kg: 10},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(60, &cutoverDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalPulletCost != 1000 {
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 200 {
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentManualPulletCost] != 1000 {
t.Fatalf("expected manual pullet cost 1000, got %v", componentTotals[hppV2ComponentManualPulletCost])
}
if componentTotals[hppV2ComponentDepreciation] != 200 {
t.Fatalf("expected depreciation 200, got %v", componentTotals[hppV2ComponentDepreciation])
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil || len(depreciation.Parts) != 1 {
t.Fatalf("expected one depreciation part, got %+v", depreciation)
}
if depreciation.Parts[0].Details["schedule_day"] != 2 {
t.Fatalf("expected schedule day 2, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["start_schedule_day"] != 2 {
t.Fatalf("expected start schedule day 2, got %+v", depreciation.Parts[0].Details)
}
if result.Hpp.Estimation.HargaKg != 20 {
t.Fatalf("expected estimation harga/kg 20, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggProduction(t *testing.T) {
reportDate := mustTime(t, "2026-06-05")
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
70: {
ProjectFlockKandangID: 70,
ProjectFlockID: 15,
ProjectFlockCategory: "LAYING",
KandangID: 700,
KandangName: "Kandang Snapshot",
LocationID: 25,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
15: {70, 71},
},
snapshotByProjectKey: map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow{
"15|2026-06-05": {
ID: 901,
ProjectFlockID: 15,
PeriodDate: reportDate,
DepreciationPercentEffective: 10,
DepreciationValue: 1000,
PulletCostDayNTotal: 10000,
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
70: {pieces: 200, kg: 20},
71: {pieces: 800, kg: 80},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(70, &reportDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected breakdown result")
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil {
t.Fatal("expected depreciation component")
}
if depreciation.Total != 200 {
t.Fatalf("expected depreciation total 200, got %v", depreciation.Total)
}
if result.TotalProductionCost != 200 {
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
}
if len(depreciation.Parts) != 1 {
t.Fatalf("expected one depreciation part, got %d", len(depreciation.Parts))
}
if depreciation.Parts[0].Code != hppV2PartDepreciationFarmSnapshot {
t.Fatalf("expected farm snapshot depreciation part, got %s", depreciation.Parts[0].Code)
}
if depreciation.Parts[0].Proration == nil || depreciation.Parts[0].Proration.Ratio != 0.2 {
t.Fatalf("expected proration ratio 0.2, got %+v", depreciation.Parts[0].Proration)
}
if depreciation.Parts[0].Details["snapshot_id"] != uint(901) {
t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details)
}
}
func stubKey(ids []uint, flags []string) string {
idParts := make([]string, 0, len(ids))
for _, id := range ids {
idParts = append(idParts, fmt.Sprintf("%d", id))
}
sort.Strings(idParts)
flagParts := append([]string{}, flags...)
sort.Strings(flagParts)
return strings.Join(idParts, ",") + "|" + strings.Join(flagParts, ",")
}
func mustDate(t *testing.T, raw string) *time.Time {
t.Helper()
loc, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed to load timezone: %v", err)
}
value, err := time.ParseInLocation("2006-01-02", raw, loc)
if err != nil {
t.Fatalf("failed to parse date %s: %v", raw, err)
}
return &value
}
func mustTime(t *testing.T, raw string) time.Time {
t.Helper()
value := mustDate(t, raw)
return *value
}
func expenseStubKey(ids []uint, ekspedisi bool) string {
return stubKey(ids, []string{fmt.Sprintf("ekspedisi=%t", ekspedisi)})
}
func expenseFarmKey(projectFlockID uint, ekspedisi bool) string {
return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi)
}
func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string {
return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying)))
}
@@ -0,0 +1,103 @@
package service
import (
"context"
"time"
"gorm.io/gorm"
)
const farmDepreciationSnapshotTable = "farm_depreciation_snapshots"
func NormalizeDateOnlyUTC(value time.Time) time.Time {
if value.IsZero() {
return value
}
v := value.UTC()
return time.Date(v.Year(), v.Month(), v.Day(), 0, 0, 0, 0, time.UTC)
}
func MinNonZeroDateOnlyUTC(values ...time.Time) time.Time {
var out time.Time
for _, value := range values {
if value.IsZero() {
continue
}
normalized := NormalizeDateOnlyUTC(value)
if out.IsZero() || normalized.Before(out) {
out = normalized
}
}
return out
}
func InvalidateFarmDepreciationSnapshotsFromDate(ctx context.Context, db *gorm.DB, farmIDs []uint, fromDate time.Time) error {
if db == nil {
return nil
}
if fromDate.IsZero() {
return nil
}
fromDate = NormalizeDateOnlyUTC(fromDate)
query := db.WithContext(ctx).
Table(farmDepreciationSnapshotTable).
Where("period_date >= ?", fromDate)
if len(farmIDs) > 0 {
query = query.Where("project_flock_id IN ?", farmIDs)
}
return query.Delete(nil).Error
}
func ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx context.Context, db *gorm.DB, pfkIDs []uint) ([]uint, error) {
if db == nil || len(pfkIDs) == 0 {
return []uint{}, nil
}
var projectFlockIDs []uint
if err := db.WithContext(ctx).
Table("project_flock_kandangs").
Distinct("project_flock_id").
Where("id IN ?", pfkIDs).
Pluck("project_flock_id", &projectFlockIDs).Error; err != nil {
return nil, err
}
return projectFlockIDs, nil
}
func ResolveProjectFlockIDsByExpenseID(ctx context.Context, db *gorm.DB, expenseID uint) ([]uint, error) {
if db == nil || expenseID == 0 {
return []uint{}, nil
}
query := `
WITH direct_farms AS (
SELECT DISTINCT pfk.project_flock_id
FROM expense_nonstocks ens
JOIN project_flock_kandangs pfk ON pfk.id = ens.project_flock_kandang_id
WHERE ens.expense_id = @expense_id
),
json_farms AS (
SELECT DISTINCT (jsonb_array_elements_text(e.project_flock_id::jsonb))::bigint AS project_flock_id
FROM expenses e
WHERE e.id = @expense_id
AND e.project_flock_id IS NOT NULL
)
SELECT DISTINCT project_flock_id
FROM (
SELECT project_flock_id FROM direct_farms
UNION ALL
SELECT project_flock_id FROM json_farms
) x
`
var ids []uint
if err := db.WithContext(ctx).Raw(query, map[string]any{
"expense_id": expenseID,
}).Scan(&ids).Error; err != nil {
return nil, err
}
return ids, nil
}
@@ -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
}
@@ -220,6 +220,9 @@ func shouldSkipStockableForUsable(req AllocateRequest, stockableType string) boo
if (usableType == "PROJECT_CHICKIN" || functionCode == "CHICKIN_OUT") && stockable == "PROJECT_FLOCK_POPULATION" { if (usableType == "PROJECT_CHICKIN" || functionCode == "CHICKIN_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
return true return true
} }
if (usableType == "STOCK_TRANSFER_OUT" || functionCode == "STOCK_TRANSFER_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
return true
}
return false return false
} }
@@ -496,10 +499,6 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
if len(rollbackRes.Details) > 0 { if len(rollbackRes.Details) > 0 {
result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...) result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...)
} }
minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity
if desiredQty < minDesired {
desiredQty = minDesired
}
if desiredQty <= 0 { if desiredQty <= 0 {
continue continue
@@ -702,16 +701,17 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
FlagGroupCode string `gorm:"column:flag_group_code"` FlagGroupCode string `gorm:"column:flag_group_code"`
} }
var latest row var latest row
err := tx.WithContext(ctx). latestQuery := tx.WithContext(ctx).
Table("stock_allocations"). Table("stock_allocations").
Select("flag_group_code"). Select("flag_group_code").
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID). Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
Where("engine_version = 'v2'"). Where("engine_version = 'v2'").
Where("allocation_purpose = ?", defaultAllocationPurpose()). Where("allocation_purpose = ?", defaultAllocationPurpose()).
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''"). Where("flag_group_code IS NOT NULL AND flag_group_code <> ''")
Order("id DESC"). if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
Limit(1). latestQuery = latestQuery.Where("function_code = ?", code)
Take(&latest).Error }
err := latestQuery.Order("id DESC").Limit(1).Take(&latest).Error
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" { if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
return latest.FlagGroupCode, nil return latest.FlagGroupCode, nil
} }
@@ -719,19 +719,56 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
return "", err return "", err
} }
var rules []routeRule rulesQuery := tx.WithContext(ctx).
err = tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules"). Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE"). Where("is_active = TRUE").
Where("lane = ?", string(LaneUsable)). Where("lane = ?", string(LaneUsable)).
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey). Where("legacy_type_key = ?", req.Usable.LegacyTypeKey)
Find(&rules).Error 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 { if err != nil {
return "", err return "", err
} }
if len(rules) == 0 { if len(rules) == 0 {
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey) 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 { if len(rules) > 1 {
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey) return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
} }
+8 -2
View File
@@ -23,6 +23,7 @@ type SSOClientConfig struct {
var ( var (
IsProd bool IsProd bool
AppEnv string
AppHost string AppHost string
Version string Version string
LogLevel string LogLevel string
@@ -84,7 +85,8 @@ func init() {
loadConfig() loadConfig()
// server configuration // server configuration
IsProd = viper.GetString("APP_ENV") == "prod" AppEnv = defaultString(strings.TrimSpace(viper.GetString("APP_ENV")), "development")
IsProd = AppEnv == "prod"
AppHost = viper.GetString("APP_HOST") AppHost = viper.GetString("APP_HOST")
AppPort = viper.GetInt("APP_PORT") AppPort = viper.GetInt("APP_PORT")
Version = viper.GetString("VERSION") Version = viper.GetString("VERSION")
@@ -111,7 +113,7 @@ func init() {
// Cors // Cors
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS") CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS") CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With") CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-API-Key,X-Requested-With")
CORSExposeHeaders = parseList("CORS_EXPOSE_HEADERS") CORSExposeHeaders = parseList("CORS_EXPOSE_HEADERS")
CORSAllowCredentials = viper.GetBool("CORS_ALLOW_CREDENTIALS") CORSAllowCredentials = viper.GetBool("CORS_ALLOW_CREDENTIALS")
CORSMaxAge = viper.GetInt("CORS_MAX_AGE") CORSMaxAge = viper.GetInt("CORS_MAX_AGE")
@@ -261,6 +263,10 @@ func defaultString(v, def string) string {
return v return v
} }
func LayingWeekStart() int {
return TransferToLayingGrowingMaxWeek
}
func joinPath(parts ...string) string { func joinPath(parts ...string) string {
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))
for _, part := range parts { for _, part := range parts {
@@ -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 $$ DO $$
BEGIN 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 ALTER TABLE project_chickin_details
ADD CONSTRAINT fk_project_chickin_id ADD CONSTRAINT fk_project_chickin_id
FOREIGN KEY (project_chickin_id) FOREIGN KEY (project_chickin_id)
@@ -20,7 +20,7 @@ BEGIN
ON DELETE CASCADE ON UPDATE CASCADE; ON DELETE CASCADE ON UPDATE CASCADE;
END IF; 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 ALTER TABLE project_chickin_details
ADD CONSTRAINT fk_product_warehouse_id ADD CONSTRAINT fk_product_warehouse_id
FOREIGN KEY (product_warehouse_id) FOREIGN KEY (product_warehouse_id)
@@ -28,7 +28,7 @@ BEGIN
ON DELETE RESTRICT ON UPDATE CASCADE; ON DELETE RESTRICT ON UPDATE CASCADE;
END IF; 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 ALTER TABLE project_chickin_details
ADD CONSTRAINT fk_created_by ADD CONSTRAINT fk_created_by
FOREIGN KEY (created_by) FOREIGN KEY (created_by)
@@ -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,3 @@
-- Remove convertion fields from marketing_delivery_products table
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS weight_per_convertion;
@@ -0,0 +1,4 @@
-- Add convertion fields to marketing_delivery_products table
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS weight_per_convertion NUMERIC(15, 3);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS integration_api_keys;
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS integration_api_keys (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
environment VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
key_prefix VARCHAR(64) NOT NULL,
key_hash TEXT NOT NULL,
permission_codes JSONB NOT NULL DEFAULT '[]'::jsonb,
all_area BOOLEAN NOT NULL DEFAULT FALSE,
area_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
all_location BOOLEAN NOT NULL DEFAULT FALSE,
location_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
last_used_at TIMESTAMPTZ NULL,
last_used_from VARCHAR(128) NULL,
revoked_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_integration_api_keys_environment_prefix UNIQUE (environment, key_prefix)
);
CREATE INDEX idx_integration_api_keys_status ON integration_api_keys (status);
CREATE INDEX idx_integration_api_keys_deleted_at ON integration_api_keys (deleted_at);
@@ -0,0 +1,6 @@
ALTER TABLE kandangs
DROP COLUMN IF EXISTS house_type;
DROP TABLE IF EXISTS house_depreciation_standards;
DROP TYPE IF EXISTS house_type_enum;
@@ -0,0 +1,18 @@
CREATE TYPE house_type_enum AS ENUM ('open_house', 'close_house');
CREATE TABLE house_depreciation_standards (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
effective_date DATE,
house_type house_type_enum NOT NULL,
day INT NOT NULL
CHECK (day >= 0),
depreciation_percent NUMERIC(15, 6) NOT NULL
CHECK (depreciation_percent >= 0 AND depreciation_percent <= 100),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT house_depreciation_standards_house_type_day_unique UNIQUE (house_type, day)
);
ALTER TABLE kandangs
ADD COLUMN house_type house_type_enum;
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_farm_depreciation_snapshots_project_flock_id;
DROP INDEX IF EXISTS idx_farm_depreciation_snapshots_period_date;
DROP TABLE IF EXISTS farm_depreciation_snapshots;
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS farm_depreciation_snapshots (
id BIGSERIAL PRIMARY KEY,
project_flock_id BIGINT NOT NULL
REFERENCES project_flocks(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
period_date DATE NOT NULL,
depreciation_percent_effective NUMERIC(15, 6) NOT NULL DEFAULT 0,
depreciation_value NUMERIC(18, 3) NOT NULL DEFAULT 0,
pullet_cost_day_n_total NUMERIC(18, 3) NOT NULL DEFAULT 0,
components JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT farm_depreciation_snapshots_unique UNIQUE (project_flock_id, period_date)
);
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_snapshots_period_date
ON farm_depreciation_snapshots (period_date);
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_snapshots_project_flock_id
ON farm_depreciation_snapshots (project_flock_id);
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_project_flock_id;
DROP TABLE IF EXISTS farm_depreciation_manual_inputs;
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS farm_depreciation_manual_inputs (
id BIGSERIAL PRIMARY KEY,
project_flock_id BIGINT NOT NULL
REFERENCES project_flocks(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
total_cost NUMERIC(18, 3) NOT NULL DEFAULT 0
CHECK (total_cost >= 0),
note TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT farm_depreciation_manual_inputs_unique UNIQUE (project_flock_id)
);
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_project_flock_id
ON farm_depreciation_manual_inputs (project_flock_id);
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_cutover_date;
ALTER TABLE farm_depreciation_manual_inputs
DROP COLUMN IF EXISTS cutover_date;
@@ -0,0 +1,12 @@
ALTER TABLE farm_depreciation_manual_inputs
ADD COLUMN IF NOT EXISTS cutover_date DATE;
UPDATE farm_depreciation_manual_inputs
SET cutover_date = COALESCE(cutover_date, DATE(created_at))
WHERE cutover_date IS NULL;
ALTER TABLE farm_depreciation_manual_inputs
ALTER COLUMN cutover_date SET NOT NULL;
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_cutover_date
ON farm_depreciation_manual_inputs (cutover_date);
@@ -0,0 +1,17 @@
BEGIN;
DROP INDEX IF EXISTS idx_recording_stocks_project_flock_kandang_id;
ALTER TABLE recording_stocks
DROP CONSTRAINT IF EXISTS fk_recording_stocks_project_flock_kandang_id;
ALTER TABLE recording_stocks
DROP COLUMN IF EXISTS project_flock_kandang_id;
ALTER TABLE house_depreciation_standards
DROP CONSTRAINT IF EXISTS chk_house_depreciation_standards_standard_week_positive;
ALTER TABLE house_depreciation_standards
DROP COLUMN IF EXISTS standard_week;
COMMIT;
@@ -0,0 +1,52 @@
BEGIN;
ALTER TABLE recording_stocks
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_stocks_project_flock_kandang_id'
) THEN
ALTER TABLE recording_stocks
ADD CONSTRAINT fk_recording_stocks_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_stocks_project_flock_kandang_id
ON recording_stocks(project_flock_kandang_id);
ALTER TABLE house_depreciation_standards
ADD COLUMN IF NOT EXISTS standard_week INT;
UPDATE house_depreciation_standards
SET standard_week = CASE house_type::text
WHEN 'close_house' THEN 22
WHEN 'open_house' THEN 25
ELSE standard_week
END
WHERE standard_week IS NULL OR standard_week <= 0;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_house_depreciation_standards_standard_week_positive'
) THEN
ALTER TABLE house_depreciation_standards
ADD CONSTRAINT chk_house_depreciation_standards_standard_week_positive
CHECK (standard_week > 0);
END IF;
END $$;
ALTER TABLE house_depreciation_standards
ALTER COLUMN standard_week SET NOT NULL;
COMMIT;
@@ -0,0 +1,21 @@
BEGIN;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
DROP INDEX IF EXISTS idx_daily_checklists_deleted_at;
DROP INDEX IF EXISTS idx_daily_checklists_deleted_by;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS fk_daily_checklists_deleted_by;
ALTER TABLE daily_checklists
DROP COLUMN IF EXISTS deleted_at,
DROP COLUMN IF EXISTS deleted_by;
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,27 @@
BEGIN;
ALTER TABLE daily_checklists
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS deleted_by BIGINT;
CREATE INDEX IF NOT EXISTS idx_daily_checklists_deleted_at
ON daily_checklists (deleted_at);
CREATE INDEX IF NOT EXISTS idx_daily_checklists_deleted_by
ON daily_checklists (deleted_by);
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS fk_daily_checklists_deleted_by,
ADD CONSTRAINT fk_daily_checklists_deleted_by
FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
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')
AND deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,41 @@
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM daily_checklists
WHERE category::text = 'empty_kandang'
) THEN
RAISE EXCEPTION 'Cannot rollback category_code enum: daily_checklists still contains empty_kandang';
END IF;
IF EXISTS (
SELECT 1
FROM phases
WHERE category::text = 'empty_kandang'
) THEN
RAISE EXCEPTION 'Cannot rollback category_code enum: phases still contains empty_kandang';
END IF;
END $$;
ALTER TYPE category_code RENAME TO category_code_old;
CREATE TYPE category_code AS ENUM (
'pullet_open',
'pullet_close',
'produksi_open',
'produksi_close'
);
ALTER TABLE phases
ALTER COLUMN category TYPE category_code
USING category::text::category_code;
ALTER TABLE daily_checklists
ALTER COLUMN category TYPE category_code
USING category::text::category_code;
DROP TYPE category_code_old;
COMMIT;
@@ -0,0 +1,12 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'category_code'
AND e.enumlabel = 'empty_kandang'
) THEN
ALTER TYPE category_code ADD VALUE 'empty_kandang';
END IF;
END $$;
+2
View File
@@ -5,6 +5,7 @@ import "time"
type AdjustmentStock struct { type AdjustmentStock struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` 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"` TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"`
FunctionCode string `gorm:"column:function_code;type:varchar(64)"` FunctionCode string `gorm:"column:function_code;type:varchar(64)"`
TotalQty float64 `gorm:"column:total_qty;default:0"` TotalQty float64 `gorm:"column:total_qty;default:0"`
@@ -18,5 +19,6 @@ type AdjustmentStock struct {
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"` AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` 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"` StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
} }
+10 -3
View File
@@ -1,6 +1,10 @@
package entities package entities
import "time" import (
"time"
"gorm.io/gorm"
)
type DailyChecklist struct { type DailyChecklist struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
@@ -14,12 +18,15 @@ type DailyChecklist struct {
DocumentPath *string DocumentPath *string
RejectReason *string RejectReason *string
CreatedBy *uint CreatedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"` DeletedBy *uint
UpdatedAt time.Time `gorm:"autoUpdateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"` Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"` Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
} }
@@ -0,0 +1,18 @@
package entities
import "time"
type FarmDepreciationManualInput struct {
Id uint `gorm:"primaryKey"`
ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_manual_inputs_unique"`
TotalCost float64 `gorm:"type:numeric(18,3);not null;default:0"`
CutoverDate time.Time `gorm:"type:date;not null"`
Note *string `gorm:"type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"`
}
func (FarmDepreciationManualInput) TableName() string {
return "farm_depreciation_manual_inputs"
}
@@ -0,0 +1,21 @@
package entities
import (
"time"
)
type FarmDepreciationSnapshot struct {
Id uint `gorm:"primaryKey"`
ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_snapshots_unique,priority:1"`
PeriodDate time.Time `gorm:"type:date;not null;uniqueIndex:idx_farm_depreciation_snapshots_unique,priority:2"`
DepreciationPercentEffective float64 `gorm:"type:numeric(15,6);not null;default:0"`
DepreciationValue float64 `gorm:"type:numeric(18,3);not null;default:0"`
PulletCostDayNTotal float64 `gorm:"type:numeric(18,3);not null;default:0"`
Components []byte `gorm:"type:jsonb;default:'{}'::jsonb"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (FarmDepreciationSnapshot) TableName() string {
return "farm_depreciation_snapshots"
}
@@ -0,0 +1,17 @@
package entities
import "time"
type HouseDepreciationStandard struct {
Id uint `gorm:"primaryKey"`
HouseType string `gorm:"type:house_type_enum;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:1"`
DayNumber int `gorm:"column:day;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:2"`
StandardWeek int `gorm:"column:standard_week;not null"`
DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (HouseDepreciationStandard) TableName() string {
return "house_depreciation_standards"
}
+36
View File
@@ -0,0 +1,36 @@
package entities
import (
"time"
"gorm.io/gorm"
)
const (
IntegrationAPIKeyStatusActive = "active"
IntegrationAPIKeyStatusRevoked = "revoked"
)
type IntegrationAPIKey struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);not null"`
Environment string `gorm:"type:varchar(50);not null;uniqueIndex:idx_integration_api_keys_env_prefix,priority:1"`
Status string `gorm:"type:varchar(20);not null;default:active;index"`
KeyPrefix string `gorm:"type:varchar(64);not null;uniqueIndex:idx_integration_api_keys_env_prefix,priority:2"`
KeyHash string `gorm:"type:text;not null"`
PermissionCodes []string `gorm:"type:jsonb;serializer:json;not null"`
AllArea bool `gorm:"not null;default:false"`
AreaIDs []uint `gorm:"type:jsonb;serializer:json;not null"`
AllLocation bool `gorm:"not null;default:false"`
LocationIDs []uint `gorm:"type:jsonb;serializer:json;not null"`
LastUsedAt *time.Time
LastUsedFrom string `gorm:"type:varchar(128)"`
RevokedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (IntegrationAPIKey) TableName() string {
return "integration_api_keys"
}
+1
View File
@@ -10,6 +10,7 @@ type Kandang struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` 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"` Status string `gorm:"type:varchar(50);not null"`
HouseType *string `gorm:"type:house_type_enum"`
LocationId uint `gorm:"not null"` LocationId uint `gorm:"not null"`
KandangGroupId uint `gorm:"not null"` KandangGroupId uint `gorm:"not null"`
Capacity float64 `gorm:"not null"` Capacity float64 `gorm:"not null"`
+13 -10
View File
@@ -5,20 +5,23 @@ import (
) )
type MarketingDeliveryProduct struct { type MarketingDeliveryProduct struct {
Id uint `gorm:"primaryKey;autoIncrement"` Id uint `gorm:"primaryKey;autoIncrement"`
MarketingProductId uint `gorm:"uniqueIndex;not null"` MarketingProductId uint `gorm:"uniqueIndex;not null"`
ProductWarehouseId uint `gorm:"not null"` ProductWarehouseId uint `gorm:"not null"`
UnitPrice float64 `gorm:"type:numeric(15,3)"` AttributedProjectFlockKandangId *uint `gorm:"->;column:attributed_project_flock_kandang_id"`
TotalWeight float64 `gorm:"type:numeric(15,3)"` UnitPrice float64 `gorm:"type:numeric(15,3)"`
AvgWeight float64 `gorm:"type:numeric(15,3)"` TotalWeight float64 `gorm:"type:numeric(15,3)"`
TotalPrice float64 `gorm:"type:numeric(15,3)"` AvgWeight float64 `gorm:"type:numeric(15,3)"`
DeliveryDate *time.Time `gorm:"type:timestamptz"` WeightPerConvertion *float64 `gorm:"type:numeric(15,3)"`
VehicleNumber string `gorm:"type:varchar(50)"` TotalPrice float64 `gorm:"type:numeric(15,3)"`
DeliveryDate *time.Time `gorm:"type:timestamptz"`
VehicleNumber string `gorm:"type:varchar(50)"`
// FIFO Fields // FIFO Fields
UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
PendingQty 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"` 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 package entities
type ProductWarehouse struct { type ProductWarehouse struct {
Id uint `gorm:"primaryKey;column:id"` Id uint `gorm:"primaryKey;column:id"`
ProductId uint `gorm:"column:product_id;not null"` ProductId uint `gorm:"column:product_id;not null"`
WarehouseId uint `gorm:"column:warehouse_id;not null"` WarehouseId uint `gorm:"column:warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
AvailableQty *float64 `gorm:"-"`
// Relations // Relations
Product Product `gorm:"foreignKey:ProductId;references:Id"` Product Product `gorm:"foreignKey:ProductId;references:Id"`
+2
View File
@@ -45,4 +45,6 @@ type Recording struct {
StandardFcr *float64 `gorm:"-"` StandardFcr *float64 `gorm:"-"`
PopulationCanChange *bool `gorm:"-"` PopulationCanChange *bool `gorm:"-"`
TransferExecuted *bool `gorm:"-"` TransferExecuted *bool `gorm:"-"`
IsTransition *bool `gorm:"-"`
IsLaying *bool `gorm:"-"`
} }
+11 -9
View File
@@ -1,14 +1,16 @@
package entities package entities
type RecordingDepletion struct { type RecordingDepletion struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"` RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
Qty float64 `gorm:"column:qty;not null"` SourceProjectFlockKandangId *uint `gorm:"column:source_project_flock_kandang_id"`
UsageQty float64 `gorm:"column:usage_qty"` Qty float64 `gorm:"column:qty;not null"`
PendingQty float64 `gorm:"column:pending_qty"` UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"`
} }

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