Compare commits

...

216 Commits

Author SHA1 Message Date
giovanni c8623e2f7c add export customer payment control 2026-05-19 22:42:46 +07:00
giovanni 907b695526 add export laporang keuangan hutang ke supplier 2026-05-19 18:38:58 +07:00
Giovanni Gabriel Septriadi 02b86be4c5 Merge branch 'feat/edit-dc' into 'development'
[FIX][BE]: fix detail daily checklist empty kandang; add sorting to report biaya operasional

See merge request mbugroup/lti-api!539
2026-05-19 01:47:53 +00:00
giovanni 99e185a16a ad sorting to laporan biaya operasional 2026-05-19 00:15:08 +07:00
giovanni 995d585f54 fix get detail kandang kosong 2026-05-18 22:45:30 +07:00
Giovanni Gabriel Septriadi d05be1aef4 Merge branch 'feat/edit-dc' into 'development'
[FEAT][BE]: fix get search keuangan; add bank name to supplier and customer

See merge request mbugroup/lti-api!538
2026-05-17 13:50:15 +00:00
giovanni 872a71efda fix get search keuangan; add bank name to supplier and customer 2026-05-17 20:48:13 +07:00
Giovanni Gabriel Septriadi 6a2d6eec92 Merge branch 'feat/edit-dc' into 'development'
[FEAT][BE]: daily checklist can edit empty kandanG

See merge request mbugroup/lti-api!537
2026-05-17 12:12:27 +00:00
giovanni 18f9da1eaf daily checklist can edit empty kandang kosong 2026-05-17 19:11:13 +07:00
Giovanni Gabriel Septriadi 45bbe2ab1b Merge branch 'fix/sorting' into 'development'
[FIX][BE]: add sorting transaction, report keuangan

See merge request mbugroup/lti-api!536
2026-05-16 16:41:46 +00:00
giovanni 18bd8ad1d9 add sorting transaction, report keuangan 2026-05-16 23:40:52 +07:00
Giovanni Gabriel Septriadi a40adc22d2 Merge branch 'feat/sort-po-ex' into 'development'
[FIX][BE]: adjust sorting pembelian dan expenses

See merge request mbugroup/lti-api!535
2026-05-13 08:35:17 +00:00
giovanni 04626560eb adjust sorting pembelian dan expenses 2026-05-13 15:34:24 +07:00
Giovanni Gabriel Septriadi 945683bdf5 Merge branch 'feat/sort-po-ex' into 'development'
[FEAT][BE]: add sorting server side po and expense

See merge request mbugroup/lti-api!534
2026-05-13 06:31:27 +00:00
giovanni 490c7fc9fd add sorting server side po and expense 2026-05-13 13:28:37 +07:00
Giovanni Gabriel Septriadi 4f03b631ef Merge branch 'fix/pay' into 'development'
[FIX][BE]: adjust calculate total price  marketing ayam pullet

See merge request mbugroup/lti-api!533
2026-05-13 02:41:24 +00:00
giovanni eac671fa80 adjust calculate total price marketing ayam pullet 2026-05-12 19:55:06 +07:00
Giovanni Gabriel Septriadi 845c14cf95 Merge branch 'fix/pay' into 'development'
[FIX][BE]: fix patch lunas BOP

See merge request mbugroup/lti-api!531
2026-05-12 08:52:37 +00:00
giovanni a04ae14271 fix patch lunas BOP 2026-05-12 15:50:18 +07:00
Giovanni Gabriel Septriadi 0caad642be Merge branch 'feat/paid' into 'development'
[FEAT][BE]: add api for update is paid to expense

See merge request mbugroup/lti-api!529
2026-05-11 07:08:52 +00:00
giovanni a76ab69a84 add api for update is paid to expense 2026-05-11 14:07:56 +07:00
Giovanni Gabriel Septriadi e940c30050 Merge branch 'fix/kosong-' into 'development'
[FIX][BE]: add api for update data daily checklist by id

See merge request mbugroup/lti-api!528
2026-05-11 06:46:31 +00:00
giovanni aab1c3a2d5 add api for update data daily checklist by id 2026-05-11 13:45:23 +07:00
Giovanni Gabriel Septriadi f9226a0b41 Merge branch 'fix/kandang-kosong' into 'development'
[FIX][BE]: adjust edit kandang kosong DAILY CHECKLIST

See merge request mbugroup/lti-api!527
2026-05-11 04:13:10 +00:00
giovanni bd8b149f11 adjust edit kandang kosong 2026-05-11 11:10:35 +07:00
Rivaldi A N S 68a132f4bb Merge branch 'chore/docs-openapi-postman' into 'development'
[CHORE][BE] Docs OpenAPI & Postman

See merge request mbugroup/lti-api!526
2026-05-11 03:45:00 +00:00
ValdiANS e576b73049 chore: update postman 2026-05-11 10:43:21 +07:00
ValdiANS e138547f3b chore: update openapi 2026-05-11 10:43:15 +07:00
Rivaldi A N S e7038a394b Merge branch 'fix/marketing-report-export' into 'development'
[FIX][BE] Marketing Report Export

See merge request mbugroup/lti-api!525
2026-05-11 03:21:59 +00:00
ValdiANS e0b9192e91 fix: format vehicle number in generated excel file 2026-05-11 10:04:57 +07:00
ValdiANS 748375b269 feat: add chickin list permission 2026-05-11 10:04:22 +07:00
Giovanni Gabriel Septriadi 09bc31c602 Merge branch 'fix/day-record' into 'development'
[FIX][BE]: add migration for change harga doc

See merge request mbugroup/lti-api!524
2026-05-08 09:55:31 +00:00
giovanni 6474dd57b3 add migration for change harga doc 2026-05-08 16:54:44 +07:00
Giovanni Gabriel Septriadi ececc5e5e1 Merge branch 'fix/day-record' into 'development'
add response excess day and week

See merge request mbugroup/lti-api!523
2026-05-08 08:05:51 +00:00
giovanni 83aa23f677 add response excess day and week 2026-05-08 15:04:54 +07:00
Giovanni Gabriel Septriadi 3d2bc11058 Merge branch 'fix/day-record' into 'development'
[FIX][BE]: fix calculate day recording if has laying transfer

See merge request mbugroup/lti-api!522
2026-05-08 06:59:34 +00:00
giovanni c328b9a880 fix calculate day recording if has laying transfer 2026-05-08 13:55:48 +07:00
Giovanni Gabriel Septriadi e29ceffa37 Merge branch 'feat/api-stock' into 'development'
[FEAT][BE]: add api get list stock log by product warehouse id

See merge request mbugroup/lti-api!521
2026-05-08 05:24:30 +00:00
Giovanni Gabriel Septriadi 8f7762f769 Merge branch 'fix/week-record' into 'development'
[FIX][BE]: adjust calculate for week at recording list

See merge request mbugroup/lti-api!520
2026-05-08 05:15:42 +00:00
Giovanni Gabriel Septriadi 4c31587771 Merge branch 'fix/record' into 'development'
[FIX][BE]: adjust export recording jumlah sapronak

See merge request mbugroup/lti-api!519
2026-05-08 04:29:04 +00:00
giovanni ecac927583 adjust export recording jumlah sapronak 2026-05-08 11:26:26 +07:00
giovanni 126294d288 add api get list stock log by product warehouse id 2026-05-07 22:39:36 +07:00
giovanni aa5d4ab818 adjust calculate for week at recording list 2026-05-07 21:09:10 +07:00
Giovanni Gabriel Septriadi 06070871c7 Merge branch 'fix/chickinsql' into 'development'
[FIX][BE]: add migration for update day recording pullet cikaum 1 dan 2

See merge request mbugroup/lti-api!517
2026-05-07 10:26:29 +00:00
giovanni 5511dc78dc add migration for update day recording pullet cikaum 1 dan 2 2026-05-07 17:24:46 +07:00
Rivaldi A N S 29f8b4fbdd Merge branch 'fix/marketing-report-export' into 'development'
[FIX][BE] Marketing Report Export

See merge request mbugroup/lti-api!516
2026-05-07 10:17:03 +00:00
ValdiANS 4c6942c7b7 fix: adjust exported file column order and copywriting 2026-05-07 17:16:06 +07:00
Giovanni Gabriel Septriadi 699e4448d1 Merge branch 'fix/marketing' into 'development'
[FIX][BE]: add field name to document

See merge request mbugroup/lti-api!515
2026-05-07 09:05:05 +00:00
giovanni 6f02387d69 add field name to document 2026-05-07 16:03:47 +07:00
Giovanni Gabriel Septriadi fc5d5d8ad4 Merge branch 'fix/marketing' into 'development'
[FIX][BE]: adjust path document for detail biaya

See merge request mbugroup/lti-api!514
2026-05-07 08:46:29 +00:00
giovanni 0d6ab5e718 adjust path document for detail biaya 2026-05-07 15:45:39 +07:00
Giovanni Gabriel Septriadi 547fc309f5 Merge branch 'fix/marketing' into 'development'
add query sort by grand total

See merge request mbugroup/lti-api!513
2026-05-07 07:12:11 +00:00
giovanni 094e8f904b add query sort by grand total 2026-05-07 14:11:39 +07:00
Giovanni Gabriel Septriadi 0d928d5856 Merge branch 'fix/marketing' into 'development'
[FIX][BE]: add sorting at marketing

See merge request mbugroup/lti-api!512
2026-05-07 07:04:22 +00:00
giovanni 0357531e73 add sorting at marketing 2026-05-07 14:03:17 +07:00
Giovanni Gabriel Septriadi 2fa279c073 Merge branch 'feat/export-recording-a' into 'development'
[FEAT][BE]: add feed use at export excel recording

See merge request mbugroup/lti-api!511
2026-05-07 03:58:19 +00:00
giovanni 90ed035abd add feed use at export excel recording 2026-05-07 10:56:30 +07:00
Rivaldi A N S 81b9e88bb6 Merge branch 'fix/marketing-report-pdf' into 'development'
[FIX][BE] Marketing Report PDF

See merge request mbugroup/lti-api!510
2026-05-06 06:14:45 +00:00
ValdiANS 7e01d8afb9 fix: adjust marketing report pdf column and copywriting 2026-05-06 13:13:02 +07:00
Giovanni Gabriel Septriadi d5a98b95dc Merge branch 'fix/result' into 'development'
[FIX][BE]: fix woa at hasil produksi

See merge request mbugroup/lti-api!508
2026-05-05 09:39:35 +00:00
giovanni 8900937e71 fix woa at hasil produksi 2026-05-05 16:38:37 +07:00
Giovanni Gabriel Septriadi cad80e2216 Merge branch 'fix/chickin' into 'development'
[FIX][BE]: add migration for edit chickin_date pullet cikaum 1 dan pullet cikaum 2

See merge request mbugroup/lti-api!507
2026-05-05 08:45:52 +00:00
Giovanni Gabriel Septriadi 34dad85734 Merge branch 'fix/kosong' into 'development'
[FIX][BE]: fix laporan daily checklist kandang kosong

See merge request mbugroup/lti-api!506
2026-05-05 08:44:51 +00:00
giovanni b1d2d30773 add migration for edit chickin_date pullet cikaum 1 dan pullet cikaum 2 2026-05-05 15:44:07 +07:00
giovanni f910d165e4 fix laporan daily checklist kandang kosong 2026-05-05 14:06:41 +07:00
M1 AIR 6f6985ef32 ci: ignore partial aws env during ecr login 2026-05-05 13:32:16 +07:00
M1 AIR d07f074fb1 fix(migrate): align recording day constraint with zero-based migration 2026-05-05 12:09:02 +07:00
Giovanni Gabriel Septriadi 561481679e Merge branch 'fix/stock-log' into 'development'
[FIX][BE]: fixing stock log when editing recording

See merge request mbugroup/lti-api!503
2026-05-05 03:08:41 +00:00
Giovanni Gabriel Septriadi d86577f007 Merge branch 'feat/export-po' into 'development'
[FEAT][BE]: add kolom gudang tujuan to export PURCHASE

See merge request mbugroup/lti-api!504
2026-05-05 03:08:01 +00:00
giovanni 49ea3f0295 add command for fix stock log missmatch 2026-05-05 10:06:35 +07:00
giovanni 7bab8c66c1 add gudang tujuan to po 2026-05-05 06:41:10 +07:00
giovanni f9de4d28f9 fixing stock log when editing recording 2026-05-04 23:06:13 +07:00
Rivaldi A N S 45028212e1 Merge branch 'fix/daily-checklist' into 'development'
[FIX][BE] Daily Checklist

See merge request mbugroup/lti-api!502
2026-05-04 09:39:23 +00:00
ValdiANS 0f285dc684 Merge branch 'fix/daily-checklist' of https://gitlab.com/mbugroup/lti-api into fix/daily-checklist 2026-05-04 16:36:05 +07:00
ValdiANS d0cd82c703 Merge branch 'development' into fix/daily-checklist 2026-05-04 16:29:07 +07:00
ValdiANS 48351661c5 fix: add order_by and sort_by query to master data employee 2026-05-04 16:28:03 +07:00
ValdiANS 19d7cd33ca fix: add search for Kandang Kosong 2026-05-04 16:27:50 +07:00
Giovanni Gabriel Septriadi 03474dc1fa Merge branch 'feat/umur' into 'development'
[FEAT][BE]: adjust calculate umur ayam at recording

See merge request mbugroup/lti-api!500
2026-05-04 06:38:19 +00:00
Rivaldi A N S 916fa4205b Merge branch 'fix/master-data-kandang' into 'development'
[FIX][BE] Master Data Kandang

See merge request mbugroup/lti-api!501
2026-05-04 04:59:12 +00:00
ValdiANS b2be67e052 fix: add sort_by and order_by query in master data kandang and kandang groups API 2026-05-04 11:54:19 +07:00
giovanni 0ac40adb5a adjust calculate umur ayam at recording 2026-05-04 11:30:53 +07:00
Adnan Zahir cee59c2b99 Merge branch 'fix/recording-validation' into 'development'
fix: allow editing sold egg weight on recording

See merge request mbugroup/lti-api!499
2026-05-02 17:05:00 +07:00
Adnan Zahir da99bf1429 fix: allow editing sold egg weight on recording 2026-05-02 17:03:57 +07:00
Adnan Zahir 28fd711ece Merge branch 'feat/fix-openapi-dashboard-integration' into 'development'
fix: resolve dashboard OpenAPI integration issues

See merge request mbugroup/lti-api!497
2026-05-02 10:59:28 +07:00
Adnan Zahir 3768892a17 fix: resolve dashboard OpenAPI integration issues
- FCRs & Transfer to Laying: add ExampleResponse field to routeMeta and
  inject example payloads into OpenAPI 200 responses for list and detail
  endpoints so dashboard consumers have concrete response shapes to work with

- Chick In: enable GET /api/production/chickins/ list endpoint (was
  commented out); add P_ChickinsGetAll permission constant and wire it
  into the route; add OpenAPI spec entry with query params and example

- Recording GET all: fix N+1 query bottleneck (2-3s response time) by
  pre-fetching approved transfer maps per PFK ID in two batch queries
  before the per-recording loop; add evaluatePopulationMutationStateFromCaches
  that uses the pre-fetched maps and caches hasAnyRecordingOnTransferTargets
  results by transfer ID — reducing per-page query count from ~20-40 to ~10-12

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 10:57:45 +07:00
Giovanni Gabriel Septriadi c804c59f05 Merge branch 'feat/empty' into 'development'
[FEAT][BE]: add search nominal keuangan

See merge request mbugroup/lti-api!496
2026-04-30 08:56:19 +00:00
giovanni b219bf829f add search nominal keuangan 2026-04-30 15:54:56 +07:00
Giovanni Gabriel Septriadi f97b1a6484 Merge branch 'feat/empty' into 'development'
[FIX][BE]: adjust empty kandang daily checklist

See merge request mbugroup/lti-api!495
2026-04-30 04:16:19 +00:00
giovanni 0d2cdef10f adjust empty kandang daily checklist 2026-04-30 11:15:11 +07:00
Adnan Zahir 128c8e0d08 Merge branch 'feat/toggle-negative-usgae' into 'development'
fix: hide legace unflagged products to be consistent with the validation

See merge request mbugroup/lti-api!493
2026-04-29 12:18:56 +07:00
Adnan Zahir cf4e723f64 fix: hide legace unflagged products to be consistent with the validation 2026-04-29 11:55:35 +07:00
Giovanni Gabriel Septriadi a3156a156f Merge branch 'feat/export-penjualan' into 'development'
[FEAT][BE]: add export excel and pdf report penjualan

See merge request mbugroup/lti-api!492
2026-04-29 04:53:03 +00:00
giovanni 16ac54ff39 add export excel and pdf report penjualan 2026-04-29 11:52:03 +07:00
Giovanni Gabriel Septriadi 196bbf4277 Merge branch 'fix/consolidate-pw' into 'development'
[FEAT][BE]: adjust query consolidate duplicate product warehouses

See merge request mbugroup/lti-api!491
2026-04-28 11:26:03 +00:00
giovanni d3053d6417 adjust query consolidate duplicate product warehouses 2026-04-28 18:23:17 +07:00
Giovanni Gabriel Septriadi 27302ea6b5 Merge branch 'fix/create-flock' into 'development'
[FEAT][BE]: adjust periode at create project flock

See merge request mbugroup/lti-api!490
2026-04-28 09:01:46 +00:00
giovanni 76ac1d1671 adjust periode at create project flock 2026-04-28 16:00:35 +07:00
Adnan Zahir 44cbc1b304 Merge branch 'feat/toggle-negative-usgae' into 'development'
fix: flock label on farm-level products

See merge request mbugroup/lti-api!489
2026-04-28 13:59:06 +07:00
Adnan Zahir e4d4bd9483 fix: flock label on farm-level products 2026-04-28 13:47:16 +07:00
Adnan Zahir cea79bc569 Merge branch 'feat/toggle-negative-usgae' into 'development'
fix: onToggle return undefined

See merge request mbugroup/lti-api!488
2026-04-28 12:34:52 +07:00
Adnan Zahir 38cfc6b103 fix: onToggle return undefined 2026-04-28 12:30:52 +07:00
Adnan Zahir 80e4a04875 Merge branch 'feat/toggle-negative-usgae' into 'development'
fix: missing useAuth

See merge request mbugroup/lti-api!487
2026-04-28 12:07:54 +07:00
Adnan Zahir 9513da2a7c fix: missing useAuth 2026-04-28 12:07:36 +07:00
Adnan Zahir 874cefda45 Merge branch 'feat/toggle-negative-usgae' into 'development'
fix: getAll and update response

See merge request mbugroup/lti-api!486
2026-04-28 11:53:14 +07:00
Adnan Zahir 795f201a0b fix: getAll and update response 2026-04-28 11:52:32 +07:00
Adnan Zahir 49d684ebbe Merge branch 'feat/toggle-negative-usgae' into 'development'
feat: konfigurasi sistem toggle pemakaian pakan ovk negatif

See merge request mbugroup/lti-api!485
2026-04-28 11:23:37 +07:00
Adnan Zahir 6f6541d4c1 feat: konfigurasi sistem toggle pemakaian pakan ovk negatif 2026-04-28 10:51:54 +07:00
Giovanni Gabriel Septriadi 1d6e1fa5be Merge branch 'feat/limit' into 'development'
[FEAT][BE]: get field type to response detail recording

See merge request mbugroup/lti-api!484
2026-04-27 06:39:02 +00:00
giovanni aadd87852b get field type to response detail recording 2026-04-27 13:38:13 +07:00
Giovanni Gabriel Septriadi ac69ae054a Merge branch 'feat/limit' into 'development'
[FEAT][BE]: adjust validation limit max 100

See merge request mbugroup/lti-api!483
2026-04-27 04:55:21 +00:00
giovanni 7cc5c39092 adjust validation limit max 100 2026-04-27 11:54:29 +07:00
Giovanni Gabriel Septriadi 706303980b Merge branch 'feat/periode-flock' into 'development'
[FEAT][BE]: adjust suffix flock name with max periode

See merge request mbugroup/lti-api!482
2026-04-27 04:25:02 +00:00
giovanni d7a2a5a2ed adjust suffix flock name with max periode 2026-04-27 11:24:21 +07:00
Giovanni Gabriel Septriadi ae47493109 Merge branch 'feat/periode-flock' into 'development'
[FEAT][BE]: add feature edit periode project flock

See merge request mbugroup/lti-api!481
2026-04-27 03:55:02 +00:00
giovanni 9726303eeb add feature edit periode project flock 2026-04-27 10:54:02 +07:00
Adnan Zahir b764d2c3c0 Merge branch 'codex/filter-improvement' into 'development'
Codex/po date

See merge request mbugroup/lti-api!479
2026-04-25 22:50:20 +07:00
Adnan Zahir eefc9850e1 feat: editable po_date 2026-04-25 22:47:52 +07:00
Adnan Zahir 732ebd423d feat: input po_date manual 2026-04-25 22:36:13 +07:00
Adnan Zahir 27d076b817 feat: expose received_date in laporan pembelian 2026-04-25 22:24:28 +07:00
Adnan Zahir 9a9cc114fe Merge branch 'codex/filter-improvement' into 'development'
feat: update numeric tolerance for fcr

See merge request mbugroup/lti-api!477
2026-04-25 15:03:17 +07:00
Adnan Zahir af5f3dc7d4 feat: update numeric tolerance for fcr 2026-04-25 14:59:56 +07:00
Adnan Zahir e1f8d357c5 Merge branch 'codex/filter-improvement' into 'development'
feat: add flag ayam for chickin

See merge request mbugroup/lti-api!475
2026-04-25 14:04:49 +07:00
Adnan Zahir f6b37926e9 feat: add flag ayam for chickin 2026-04-25 14:02:22 +07:00
Adnan Zahir 4e7c0e64ad Merge branch 'codex/filter-improvement' into 'development'
feat: add more filters

See merge request mbugroup/lti-api!474
2026-04-25 12:27:46 +07:00
Adnan Zahir e79fde2408 feat: add more filters 2026-04-25 12:15:55 +07:00
Adnan Zahir 28394ee727 Merge branch 'cmd/auto-transfer-products-to-farm' into 'development'
cmd: two steps auto transfer

See merge request mbugroup/lti-api!472
2026-04-24 21:19:33 +07:00
Adnan Zahir 42c088e772 cmd: two steps auto transfer 2026-04-24 21:17:41 +07:00
Adnan Zahir df45803f8d Merge branch 'cmd/auto-transfer-products-to-farm' into 'development'
cmd: skip ambiguous farm then resolve double farm warehouses

See merge request mbugroup/lti-api!471
2026-04-24 20:18:59 +07:00
Adnan Zahir 6033535894 cmd: skip ambiguous farm then resolve double farm warehouses 2026-04-24 17:37:04 +07:00
Adnan Zahir 62723e5ab2 Merge branch 'cmd/auto-transfer-products-to-farm' into 'development'
cmd: add disticnt farm warehouse validation

See merge request mbugroup/lti-api!470
2026-04-24 15:57:42 +07:00
Adnan Zahir f1d7966e2f cmd: add disticnt farm warehouse validation 2026-04-24 15:55:59 +07:00
Adnan Zahir b94b16ab73 Merge branch 'cmd/auto-transfer-products-to-farm' into 'development'
cmd: new command to auto transfer leftover stocks from kandang to farm

See merge request mbugroup/lti-api!468
2026-04-24 13:59:44 +07:00
Adnan Zahir 9fd9c441d3 cmd: new command to auto transfer leftover stocks from kandang to farm 2026-04-24 13:58:11 +07:00
Adnan Zahir f5d986bf27 Merge branch 'fix/hppv2-egg-production-double-count' into 'development'
fix: change pakan cutover flag to PAKAN

See merge request mbugroup/lti-api!466
2026-04-24 13:09:41 +07:00
Adnan Zahir 1e9a51b8cd fix: change pakan cutover flag to PAKAN 2026-04-24 13:06:57 +07:00
Adnan Zahir 512d616867 Merge branch 'fix/hppv2-egg-production-double-count' into 'development'
fix: remove date filter when getting adjustment eggs

See merge request mbugroup/lti-api!465
2026-04-24 11:52:20 +07:00
Adnan Zahir 50ce780c2c fix: remove date filter when getting adjustment eggs 2026-04-24 11:51:23 +07:00
Adnan Zahir bacc7217b3 Merge branch 'fix/hppv2-egg-production-double-count' into 'development'
fix: add debug_values

See merge request mbugroup/lti-api!464
2026-04-24 11:40:29 +07:00
Adnan Zahir 878b8ccce9 fix: add debug_values 2026-04-24 11:39:12 +07:00
Adnan Zahir 5c39237ac0 Merge branch 'fix/hppv2-egg-production-double-count' into 'development'
Fix/hppv2 egg production double count

See merge request mbugroup/lti-api!463
2026-04-24 03:22:56 +07:00
Adnan Zahir 0fc6096637 fix: remove join query between adjustment_stocks and recording (egg) 2026-04-24 03:22:21 +07:00
Adnan Zahir 8389bd579b fix: revert removal of adjustment egg weight 2026-04-24 03:10:24 +07:00
Adnan Zahir cf9e60c64d Merge branch 'fix/hppv2-egg-production-double-count' into 'development'
fix: attempt on fixing double counted egg in hpp v2 calculation

See merge request mbugroup/lti-api!462
2026-04-24 02:37:59 +07:00
Adnan Zahir 2dcfd9efde fix: attemnt on fixing double counted egg in hpp v2 calculation 2026-04-24 02:33:48 +07:00
Adnan Zahir adcd4dc8fb Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: check validate and fix bug risks in commands

See merge request mbugroup/lti-api!460
2026-04-24 01:49:27 +07:00
Adnan Zahir 9a19bff8d3 cmd: check validate and fix bug risks in commands 2026-04-24 01:48:30 +07:00
Adnan Zahir 596b5ed2fb Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: broaden product_warehouse_id relation normalization

See merge request mbugroup/lti-api!459
2026-04-24 01:38:22 +07:00
Adnan Zahir 20479c2ca2 cmd: broaden product_warehouse_id relation normalization 2026-04-24 01:36:58 +07:00
Adnan Zahir a79213882d Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: fix unclosed

See merge request mbugroup/lti-api!458
2026-04-24 01:31:50 +07:00
Adnan Zahir befb351e49 cmd: fix unclosed 2026-04-24 01:30:43 +07:00
Adnan Zahir 5d38dfac69 Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: fix aggregation is not allowed in FILTER

See merge request mbugroup/lti-api!457
2026-04-24 01:24:30 +07:00
Adnan Zahir 26a04d3bdd cmd: fix aggregation is not allowed in FILTER 2026-04-24 01:23:27 +07:00
Adnan Zahir 044649abc9 Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: post-consolidation command for duplicate product_warehouses

See merge request mbugroup/lti-api!456
2026-04-24 01:20:39 +07:00
Adnan Zahir 83eab9de17 cmd: post-consolidation command for duplicate product_warehouses 2026-04-24 01:18:10 +07:00
Adnan Zahir 940899dad8 Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: still invalid field

See merge request mbugroup/lti-api!455
2026-04-24 01:06:54 +07:00
Adnan Zahir ffccd6daee cmd: still invalid field 2026-04-24 01:05:38 +07:00
Adnan Zahir 7f9604bcb4 Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: invalid field error

See merge request mbugroup/lti-api!454
2026-04-24 01:01:34 +07:00
Adnan Zahir e5e090aefe cmd: invalid field error 2026-04-24 01:00:53 +07:00
Adnan Zahir 552de15c1a Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: fix id is ambigous

See merge request mbugroup/lti-api!453
2026-04-24 00:57:35 +07:00
Adnan Zahir 8e396d758f cmd: fix id is ambigous 2026-04-24 00:56:56 +07:00
Adnan Zahir 84b76a4b2d Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: fix wrong parameter parsing

See merge request mbugroup/lti-api!452
2026-04-24 00:55:05 +07:00
Adnan Zahir 7c4664844c cmd: fix wrong parameter parsing 2026-04-24 00:54:20 +07:00
Adnan Zahir 47d2371b7f Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: only check blocked references if delete kandang warehouse flag enabled

See merge request mbugroup/lti-api!451
2026-04-24 00:46:56 +07:00
Adnan Zahir 5bfd93e300 cmd: only check blocked references if delete kandang warehouse flag enabled 2026-04-24 00:45:34 +07:00
Adnan Zahir 3fa75e0a31 Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: skip consolidation for incomplete locations (locations without farm-level warehouse)

See merge request mbugroup/lti-api!450
2026-04-24 00:27:36 +07:00
Adnan Zahir 101a83cd9e cmd: skip consolidation for incomplete locations (locations without farm-level warehouse) 2026-04-24 00:25:38 +07:00
Adnan Zahir 13fc537171 Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: resolve active allocation repointment

See merge request mbugroup/lti-api!449
2026-04-23 23:33:12 +07:00
Adnan Zahir 46737e4a96 cmd: resolve active allocation repointment 2026-04-23 23:31:39 +07:00
Adnan Zahir 06a23ce1b5 Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: add flag to bypass strict repointment

See merge request mbugroup/lti-api!448
2026-04-23 22:53:27 +07:00
Adnan Zahir fb9915759b cmd: add flag to bypass strict repointment 2026-04-23 22:52:44 +07:00
Adnan Zahir f0e364f884 Merge branch 'cmd/consolidate-and-repoint-stocks' into 'development'
cmd: p.type doesn't exist

See merge request mbugroup/lti-api!447
2026-04-23 22:43:01 +07:00
Adnan Zahir 65299d7913 cmd: p.type doesn't exist 2026-04-23 22:41:32 +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
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 88b6e2f294 adjust sql migration 2026-04-02 11:40:38 +07:00
giovanni 36b0f97897 fix upser daily checklist status rejected; fix search list daily checklist 2026-04-02 11:24:53 +07:00
217 changed files with 21005 additions and 958 deletions
+1
View File
@@ -30,3 +30,4 @@ coverage/
.idea/
*.swp
.DS_Store
.gemini/
+18 -4
View File
@@ -27,10 +27,24 @@ workflow:
.ecr_login: &ecr_login |
AWS_CLI_ENV_ARGS=""
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}"
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
HAS_ACCESS_KEY="false"
HAS_SECRET_KEY="false"
if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then
HAS_ACCESS_KEY="true"
fi
if [ -n "${AWS_SECRET_ACCESS_KEY:-}" ]; then
HAS_SECRET_KEY="true"
fi
if [ "$HAS_ACCESS_KEY" = "true" ] && [ "$HAS_SECRET_KEY" = "true" ]; then
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY"
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
fi
elif [ "$HAS_ACCESS_KEY" = "true" ] || [ "$HAS_SECRET_KEY" = "true" ] || [ -n "${AWS_SESSION_TOKEN:-}" ]; then
echo "WARN: Incomplete AWS_* env vars detected; ignoring injected AWS credentials for ECR login."
fi
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,601 @@
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"
)
// ── Fake executor ─────────────────────────────────────────────────────────────
type fakeSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer
createErrors []error
deletedTransferIDs []uint
deleteErrors map[uint]error
}
func (f *fakeSystemTransferExecutor) CreateSystemTransfer(_ 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(_ context.Context, id uint, _ uint) error {
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
if f.deleteErrors != nil {
return f.deleteErrors[id]
}
return nil
}
// ── applyFarmWarehouseOverride ────────────────────────────────────────────────
func TestApplyOverrideResolvesMultiFarmLocation(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{
{ID: 50, Name: "Farm A"},
{ID: 51, Name: "Farm B"},
},
},
}
if err := applyFarmWarehouseOverride(farmMap, 51); err != nil {
t.Fatalf("unexpected error: %v", err)
}
info := farmMap[10]
if info.ChosenID != 51 {
t.Errorf("expected ChosenID=51, got %d", info.ChosenID)
}
if info.ChosenName != "Farm B" {
t.Errorf("expected ChosenName=Farm B, got %s", info.ChosenName)
}
if len(info.OtherFarm) != 1 || info.OtherFarm[0].ID != 50 {
t.Errorf("expected OtherFarm=[Farm A], got %+v", info.OtherFarm)
}
}
func TestApplyOverrideErrorsWhenIDNotInAllFarm(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{
{ID: 50, Name: "Farm A"},
{ID: 51, Name: "Farm B"},
},
},
}
err := applyFarmWarehouseOverride(farmMap, 99)
if err == nil {
t.Fatal("expected error for unknown warehouse id, got nil")
}
if !strings.Contains(err.Error(), "99") {
t.Errorf("error should mention the invalid id, got: %v", err)
}
if !strings.Contains(err.Error(), "Farm A") || !strings.Contains(err.Error(), "Farm B") {
t.Errorf("error should list available warehouses, got: %v", err)
}
}
func TestApplyOverrideIgnoresSingleFarmLocations(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Jamali",
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
ChosenID: 50,
ChosenName: "Farm A",
},
}
// Override ID 50 is present, but there is only 1 farm; the function should
// not touch this location (no OtherFarm to populate).
if err := applyFarmWarehouseOverride(farmMap, 50); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(farmMap[10].OtherFarm) != 0 {
t.Errorf("expected no OtherFarm for single-farm location, got %+v", farmMap[10].OtherFarm)
}
}
func TestApplyOverrideNoopWhenZero(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{
{ID: 50, Name: "Farm A"},
{ID: 51, Name: "Farm B"},
},
},
}
if err := applyFarmWarehouseOverride(farmMap, 0); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if farmMap[10].ChosenID != 0 {
t.Errorf("expected ChosenID unchanged (0), got %d", farmMap[10].ChosenID)
}
}
// ── listUnresolvedLocations ───────────────────────────────────────────────────
func TestListUnresolvedLocationsReturnsOnlyAmbiguous(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
1: {LocationID: 1, LocationName: "Jamali", AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50},
2: {
LocationID: 2,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
// ChosenID = 0: unresolved
},
3: {LocationID: 3, LocationName: "Tamansari", AllFarm: nil}, // no farm at all, not an error here
}
msgs := listUnresolvedLocations(farmMap)
if len(msgs) != 1 {
t.Fatalf("expected 1 unresolved message, got %d: %v", len(msgs), msgs)
}
if !strings.Contains(msgs[0], "Cijangkar") {
t.Errorf("message should name the ambiguous location, got: %s", msgs[0])
}
if !strings.Contains(msgs[0], "Farm X") || !strings.Contains(msgs[0], "Farm Y") {
t.Errorf("message should list available warehouses, got: %s", msgs[0])
}
}
// ── buildTransferPlan — kandang source ───────────────────────────────────────
func TestBuildPlanKandangEligibleGroupedByWarehousePair(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
}
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "K1", ProductID: 2, ProductName: "OVK B", OnHandQty: 50, AllocatedQty: 10, LeftoverQty: 40, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 1 || len(groups[0].Rows) != 2 {
t.Fatalf("expected 1 group with 2 rows, got %d groups", len(groups))
}
if groups[0].SourceType != sourceTypeKandang {
t.Errorf("expected group source type kandang_to_farm, got %s", groups[0].SourceType)
}
for _, row := range reportRows {
if row.Status != "eligible" {
t.Errorf("expected eligible, got %s for %s", row.Status, row.ProductName)
}
}
if reportRows[1].Qty != 40 {
t.Errorf("expected leftover qty 40 for OVK B, got %.3f", reportRows[1].Qty)
}
}
func TestBuildPlanSkipsMissingFarmWarehouse(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", AllFarm: nil},
}
stocks := []kandangStockRow{
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 {
t.Fatalf("expected no groups, got %d", len(groups))
}
if reportRows[0].Status != "skipped" || reportRows[0].Reason != "missing_farm_warehouse" {
t.Errorf("unexpected: %s / %s", reportRows[0].Status, reportRows[0].Reason)
}
}
func TestBuildPlanErrorForUnresolvedMultiFarmWarehouse(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
// ChosenID = 0: unresolved
},
}
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 {
t.Fatalf("expected no groups, got %d", len(groups))
}
if reportRows[0].Status != "error" {
t.Errorf("expected error status, got %s", reportRows[0].Status)
}
if !strings.Contains(reportRows[0].Reason, "multiple_farm_warehouses") {
t.Errorf("reason should mention multiple_farm_warehouses, got: %s", reportRows[0].Reason)
}
// The error message must list the available warehouses so the operator knows
// which --farm-warehouse-id to use.
if !strings.Contains(reportRows[0].Reason, "Farm X") || !strings.Contains(reportRows[0].Reason, "Farm Y") {
t.Errorf("reason should list available warehouses, got: %s", reportRows[0].Reason)
}
}
func TestBuildPlanSkipsFullyAllocatedKandangStock(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
}
stocks := []kandangStockRow{
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeKandang},
{LocationID: 10, SourceWarehouseID: 20, ProductID: 2, ProductName: "OVK B", OnHandQty: 80, AllocatedQty: 30, LeftoverQty: 50, SourceType: sourceTypeKandang},
}
_, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 1 || len(groups[0].Rows) != 1 {
t.Fatalf("expected 1 group with 1 eligible row, got groups=%d", len(groups))
}
if groups[0].Rows[0].ProductName != "OVK B" {
t.Errorf("expected only OVK B to be eligible, got %s", groups[0].Rows[0].ProductName)
}
}
func TestBuildPlanSkipAmbiguousDowngradesErrorToSkipped(t *testing.T) {
opts := &commandOptions{RunID: "test-run", SkipAmbiguous: true}
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
// ChosenID = 0: unresolved
},
11: {
LocationID: 11,
LocationName: "Jamali",
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
ChosenID: 50,
ChosenName: "Farm A",
},
}
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200, SourceType: sourceTypeKandang},
{LocationID: 11, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
// Ambiguous location must be skipped, not error.
ambiguous := reportRows[0]
if ambiguous.LocationName != "Cijangkar" {
t.Fatalf("expected first row to be Cijangkar, got %s", ambiguous.LocationName)
}
if ambiguous.Status != "skipped" {
t.Errorf("expected skipped with --skip-ambiguous, got %s", ambiguous.Status)
}
if !strings.Contains(ambiguous.Reason, "multiple_farm_warehouses") {
t.Errorf("reason should still explain the cause, got: %s", ambiguous.Reason)
}
// Unambiguous location must still be eligible and grouped.
if len(groups) != 1 || groups[0].LocationName != "Jamali" {
t.Errorf("expected 1 group for Jamali, got %d groups", len(groups))
}
}
// ── applyFlagFilter (unit-level, via buildTransferPlan) ───────────────────────
// applyFlagFilter is a DB-level filter so we test its effect indirectly: the
// flag filter is applied before rows reach buildTransferPlan, so we simulate
// by only passing stock rows that the query would have returned.
// The real guard is that loadKandangLeftoverStocks receives the filtered set.
// Here we verify that buildTransferPlan itself is agnostic to the filter and
// simply processes whatever rows it is given.
func TestBuildPlanOnlyTransfersRowsPassedToIt(t *testing.T) {
opts := &commandOptions{RunID: "test-run", FlagFilter: []string{"PAKAN"}}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
}
// Simulate: only PAKAN products survived the DB filter; OVK was excluded.
stocks := []kandangStockRow{
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan Broiler", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
}
_, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 1 || len(groups[0].Rows) != 1 {
t.Fatalf("expected 1 group with 1 row, got %d groups", len(groups))
}
if groups[0].Rows[0].ProductName != "Pakan Broiler" {
t.Errorf("unexpected product: %s", groups[0].Rows[0].ProductName)
}
}
// ── buildTransferPlan — farm_consolidation source ────────────────────────────
func TestBuildPlanFarmConsolidationCreatesOwnGroup(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
// Location 10 has 2 farm warehouses; Farm B (id=51) was chosen, Farm A
// (id=50) is OtherFarm whose stocks need consolidating.
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}, {ID: 51, Name: "Farm B"}},
ChosenID: 51,
ChosenName: "Farm B",
OtherFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
},
}
// Extra farm stock from Farm A + normal kandang stock.
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 20, SourceWarehouseName: "Kandang K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 50, SourceWarehouseName: "Farm A", ProductID: 2, ProductName: "OVK B", OnHandQty: 60, LeftoverQty: 60, SourceType: sourceTypeFarmConsol},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 2 {
t.Fatalf("expected 2 groups (one per source warehouse), got %d", len(groups))
}
if len(reportRows) != 2 {
t.Fatalf("expected 2 report rows, got %d", len(reportRows))
}
groupsBySource := make(map[uint]*transferGroup, 2)
for i := range groups {
groupsBySource[groups[i].SourceWarehouseID] = &groups[i]
}
kandangGroup := groupsBySource[20]
if kandangGroup == nil {
t.Fatal("expected a group with SourceWarehouseID=20")
}
if kandangGroup.SourceType != sourceTypeKandang {
t.Errorf("expected kandang_to_farm group, got %s", kandangGroup.SourceType)
}
if kandangGroup.FarmWarehouseID != 51 {
t.Errorf("expected kandang group to target Farm B (51), got %d", kandangGroup.FarmWarehouseID)
}
consolGroup := groupsBySource[50]
if consolGroup == nil {
t.Fatal("expected a consolidation group with SourceWarehouseID=50")
}
if consolGroup.SourceType != sourceTypeFarmConsol {
t.Errorf("expected farm_consolidation group, got %s", consolGroup.SourceType)
}
if consolGroup.FarmWarehouseID != 51 {
t.Errorf("expected consolidation group to target Farm B (51), got %d", consolGroup.FarmWarehouseID)
}
}
func TestBuildPlanFarmConsolidationSkipsZeroLeftover(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50}, {ID: 51}}, ChosenID: 51, ChosenName: "Farm B", OtherFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}},
}
stocks := []kandangStockRow{
{LocationID: 10, SourceWarehouseID: 50, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeFarmConsol},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 {
t.Fatalf("expected no groups, got %d", len(groups))
}
if reportRows[0].Status != "skipped" {
t.Errorf("expected skipped, got %s", reportRows[0].Status)
}
}
// ── executeApply ──────────────────────────────────────────────────────────────
func TestExecuteApplyTagsReasonAndNotesWithSourceType(t *testing.T) {
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
opts := &commandOptions{RunID: "run-apply", TransferDate: date, ActorID: 99}
groups := []transferGroup{
{
SourceType: sourceTypeKandang,
LocationName: "Jamali",
SourceWarehouseID: 20,
SourceWarehouseName: "K1",
FarmWarehouseID: 50,
FarmWarehouseName: "Farm A",
Rows: []*transferReportRow{{ProductID: 1, ProductName: "Pakan A", Qty: 100}},
},
{
SourceType: sourceTypeFarmConsol,
LocationName: "Cijangkar",
SourceWarehouseID: 60,
SourceWarehouseName: "Farm X",
FarmWarehouseID: 61,
FarmWarehouseName: "Farm Y",
Rows: []*transferReportRow{{ProductID: 2, ProductName: "OVK B", Qty: 40}},
},
}
executor := &fakeSystemTransferExecutor{}
summary, err := executeApply(context.Background(), executor, opts, groups)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if summary.GroupsApplied != 2 {
t.Errorf("expected 2 groups applied, got %d", summary.GroupsApplied)
}
if len(executor.createRequests) != 2 {
t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests))
}
// Both requests must carry the run_id in the reason for rollback to work.
for i, req := range executor.createRequests {
if !strings.Contains(req.TransferReason, "run_id=run-apply") {
t.Errorf("request %d reason missing run_id: %s", i, req.TransferReason)
}
}
// Notes for farm_consolidation should be distinct from kandang_to_farm.
if !strings.Contains(executor.createRequests[0].StockLogNotes, "kandang to farm") {
t.Errorf("kandang group notes should say 'kandang to farm', got: %s", executor.createRequests[0].StockLogNotes)
}
if !strings.Contains(executor.createRequests[1].StockLogNotes, "consolidation") {
t.Errorf("consolidation group notes should say 'consolidation', got: %s", executor.createRequests[1].StockLogNotes)
}
}
func TestExecuteApplyCreatesTransferWithCorrectProductsAndRecordsTransferID(t *testing.T) {
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
opts := &commandOptions{RunID: "run-1", TransferDate: date, ActorID: 1}
row1 := &transferReportRow{ProductID: 1, ProductName: "Pakan A", Qty: 100}
row2 := &transferReportRow{ProductID: 2, ProductName: "OVK B", Qty: 40}
groups := []transferGroup{
{
SourceType: sourceTypeKandang, SourceWarehouseID: 20, FarmWarehouseID: 50,
Rows: []*transferReportRow{row1, row2},
},
}
executor := &fakeSystemTransferExecutor{
createResponses: []*entity.StockTransfer{{Id: 1001, MovementNumber: "PND-LTI-1001"}},
}
_, err := executeApply(context.Background(), executor, opts, groups)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if row1.Status != "applied" || row2.Status != "applied" {
t.Errorf("both rows should be applied: %s, %s", row1.Status, row2.Status)
}
if row1.TransferID == nil || *row1.TransferID != 1001 {
t.Errorf("expected transfer id 1001, got %+v", row1.TransferID)
}
if row1.MovementNumber == nil || *row1.MovementNumber != "PND-LTI-1001" {
t.Errorf("expected movement number PND-LTI-1001, got %+v", row1.MovementNumber)
}
// Verify both products were included in the create request.
if len(executor.createRequests[0].Products) != 2 {
t.Errorf("expected 2 products in request, got %d", len(executor.createRequests[0].Products))
}
}
// ── executeRollback ───────────────────────────────────────────────────────────
func TestExecuteRollbackDeletesDescendingAndMarksStatuses(t *testing.T) {
executor := &fakeSystemTransferExecutor{
deleteErrors: map[uint]error{200: errors.New("already consumed")},
}
rows := []rollbackDetailRow{
{TransferID: 100, ProductName: "Pakan A"},
{TransferID: 200, ProductName: "OVK B"},
{TransferID: 100, ProductName: "Pakan C"},
}
err := executeRollback(context.Background(), executor, rows, 99)
if err == nil || !strings.Contains(err.Error(), "already consumed") {
t.Fatalf("expected error for transfer 200, got: %v", err)
}
if executor.deletedTransferIDs[0] != 200 || executor.deletedTransferIDs[1] != 100 {
t.Fatalf("expected delete order [200 100], got %v", executor.deletedTransferIDs)
}
if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" {
t.Errorf("transfer 100 rows must be rolled_back: %+v", rows)
}
if rows[1].Status != "failed" {
t.Errorf("transfer 200 row must be failed: %+v", rows[1])
}
}
func TestExecuteRollbackRequiresActorID(t *testing.T) {
err := executeRollback(context.Background(), &fakeSystemTransferExecutor{}, []rollbackDetailRow{{TransferID: 1}}, 0)
if err == nil || !strings.Contains(err.Error(), "actor-id") {
t.Fatalf("expected actor-id error, got: %v", err)
}
}
// ── buildTransferReason / buildRunReasonMatcher ───────────────────────────────
func TestBuildTransferReasonMatchesRunReasonMatcher(t *testing.T) {
runID := "product-farm-transfer-20260424T120000.000000000Z"
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
reason := buildTransferReason(runID, "Jamali", "Gudang K1", "Gudang Farm Jamali", date)
needle := strings.TrimSuffix(buildRunReasonMatcher(runID), "%")
if !strings.HasPrefix(reason, needle) {
t.Errorf("reason %q does not match matcher prefix %q", reason, needle)
}
}
func TestBuildTransferReasonSanitizesPipes(t *testing.T) {
reason := buildTransferReason("run-1", "Lok|asi", "Gudang|K1", "Farm|WH", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
parts := strings.Split(reason, "|")
// prefix + 5 key=value segments = 6 parts
if len(parts) != 6 {
t.Errorf("expected 6 pipe-separated segments, got %d: %v", len(parts), parts)
}
}
func TestBuildStockLogNotesContainsSourceTypeHint(t *testing.T) {
date := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
kandangNote := buildStockLogNotes("r", "Loc", "Src", "Dst", date, sourceTypeKandang)
consolNote := buildStockLogNotes("r", "Loc", "Src", "Dst", date, sourceTypeFarmConsol)
if !strings.Contains(kandangNote, "kandang to farm") {
t.Errorf("kandang note should mention 'kandang to farm': %s", kandangNote)
}
if !strings.Contains(consolNote, "consolidation") {
t.Errorf("consolidation note should mention 'consolidation': %s", consolNote)
}
}
// ── summarizeReport ───────────────────────────────────────────────────────────
func TestSummarizeReportCountsAllStatuses(t *testing.T) {
rows := []transferReportRow{
{Status: "eligible"},
{Status: "applied"},
{Status: "applied"},
{Status: "skipped"},
{Status: "error"},
{Status: "failed"},
}
groups := []transferGroup{{}, {}}
s := summarizeReport(rows, groups, 1)
if s.RowsPlanned != 4 { // eligible + 2 applied + 1 failed
t.Errorf("expected RowsPlanned=4, got %d", s.RowsPlanned)
}
if s.RowsApplied != 2 {
t.Errorf("expected RowsApplied=2, got %d", s.RowsApplied)
}
if s.RowsSkipped != 1 {
t.Errorf("expected RowsSkipped=1, got %d", s.RowsSkipped)
}
if s.RowsError != 1 {
t.Errorf("expected RowsError=1, got %d", s.RowsError)
}
if s.RowsFailed != 1 {
t.Errorf("expected RowsFailed=1, got %d", s.RowsFailed)
}
if s.GroupsPlanned != 2 || s.GroupsApplied != 1 {
t.Errorf("unexpected group counts: %+v", s)
}
}
@@ -0,0 +1,436 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"sort"
"strings"
"text/tabwriter"
commonSvc "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"
"gorm.io/gorm"
)
const (
outputTable = "table"
outputJSON = "json"
)
type options struct {
Apply bool
Output string
DBSSLMode string
AreaName string
}
type duplicateGroup struct {
WarehouseID uint `json:"warehouse_id"`
WarehouseName string `json:"warehouse_name"`
ProductID uint `json:"product_id"`
ProductName string `json:"product_name"`
AreaName string `json:"area_name"`
LocationName string `json:"location_name"`
ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"`
SurvivorID uint `json:"survivor_id"`
SurvivorQty float64 `json:"survivor_qty"`
AbsorbedCount int `json:"absorbed_count"`
TotalMergedQty float64 `json:"total_merged_qty"`
AbsorbedIDs string `json:"absorbed_ids"`
}
type consolidateSummary struct {
TotalDuplicateGroups int `json:"total_duplicate_groups"`
TotalProductWarehouses int64 `json:"total_product_warehouses"`
UpdatedReferences map[string]int64 `json:"updated_references,omitempty"`
DeletedProductWarehouses int64 `json:"deleted_product_warehouses,omitempty"`
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)
// Find duplicate groups
groups, err := findDuplicateProductWarehouses(ctx, db, opts)
if err != nil {
log.Fatalf("failed to find duplicates: %v", err)
}
if len(groups) == 0 {
fmt.Println("No duplicate product_warehouses found")
return
}
summary := summarizeGroups(groups)
if !opts.Apply {
renderConsolidation(opts.Output, groups, summary)
return
}
applied, err := applyConsolidation(ctx, db, groups)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
renderConsolidation(opts.Output, groups, applied)
}
func parseFlags() (*options, error) {
var opts options
flag.BoolVar(&opts.Apply, "apply", false, "Apply consolidation (omit for dry-run)")
flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json")
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Database sslmode override")
flag.StringVar(&opts.AreaName, "area-name", "", "Optional area filter")
flag.Parse()
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
opts.AreaName = strings.TrimSpace(opts.AreaName)
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
if opts.Output == "" {
opts.Output = outputTable
}
if opts.Output != outputTable && opts.Output != outputJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
return &opts, nil
}
func findDuplicateProductWarehouses(ctx context.Context, db *gorm.DB, opts *options) ([]duplicateGroup, error) {
filters := ""
args := []any{}
if opts.AreaName != "" {
filters = "WHERE a.name = ?"
args = append(args, opts.AreaName)
}
query := fmt.Sprintf(`
WITH duplicates AS (
SELECT
pw.warehouse_id,
w.name AS warehouse_name,
pw.product_id,
p.name AS product_name,
COALESCE(a.name, 'N/A') AS area_name,
COALESCE(l.name, 'N/A') AS location_name,
pw.project_flock_kandang_id,
pw.id,
pw.qty,
MIN(pw.id) OVER (PARTITION BY pw.warehouse_id, pw.product_id) AS survivor_id,
COUNT(*) OVER (PARTITION BY pw.warehouse_id, pw.product_id) AS duplicate_count,
SUM(pw.qty) OVER (PARTITION BY pw.warehouse_id, pw.product_id) AS total_qty
FROM product_warehouses pw
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN locations l ON l.id = w.location_id
LEFT JOIN areas a ON a.id = l.area_id
%s
)
SELECT
warehouse_id,
warehouse_name,
product_id,
product_name,
area_name,
location_name,
(SELECT project_flock_kandang_id FROM duplicates d2 WHERE d2.id = survivor_id LIMIT 1) AS project_flock_kandang_id,
survivor_id,
(SELECT qty FROM duplicates d2 WHERE d2.id = survivor_id LIMIT 1) AS survivor_qty,
duplicate_count - 1 AS absorbed_count,
total_qty AS total_merged_qty,
STRING_AGG(id::text, ', ' ORDER BY id::text) FILTER (WHERE id <> survivor_id) AS absorbed_ids
FROM duplicates
WHERE duplicate_count > 1
GROUP BY warehouse_id, warehouse_name, product_id, product_name, area_name, location_name, survivor_id, total_qty, duplicate_count
ORDER BY area_name, location_name, warehouse_name, product_name
`, filters)
rows := make([]duplicateGroup, 0)
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func applyConsolidation(ctx context.Context, db *gorm.DB, groups []duplicateGroup) (consolidateSummary, error) {
summary := consolidateSummary{
TotalDuplicateGroups: len(groups),
UpdatedReferences: make(map[string]int64),
OverallStatus: "PASS",
}
fifoSvc := commonSvc.NewFifoStockV2Service(db, nil)
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, group := range groups {
absorbedIDs := []uint{}
if group.AbsorbedIDs != "" {
parts := strings.Split(group.AbsorbedIDs, ", ")
for _, p := range parts {
var id uint
fmt.Sscanf(p, "%d", &id)
absorbedIDs = append(absorbedIDs, id)
}
}
if len(absorbedIDs) == 0 {
continue
}
// Update all references to point to survivor
refTables := []struct {
table string
column string
}{
{"stock_allocations", "product_warehouse_id"},
{"stock_logs", "product_warehouse_id"},
{"purchase_items", "product_warehouse_id"},
{"recording_stocks", "product_warehouse_id"},
{"recording_eggs", "product_warehouse_id"},
{"recording_depletions", "product_warehouse_id"},
{"recording_depletions", "source_product_warehouse_id"},
{"marketing_delivery_products", "product_warehouse_id"},
{"marketing_products", "product_warehouse_id"},
{"stock_transfer_details", "source_product_warehouse_id"},
{"stock_transfer_details", "dest_product_warehouse_id"},
{"adjustment_stocks", "product_warehouse_id"},
{"laying_transfer_sources", "product_warehouse_id"},
{"laying_transfer_targets", "product_warehouse_id"},
{"laying_transfers", "source_product_warehouse_id"},
{"project_chickin_details", "product_warehouse_id"},
{"project_chickins", "product_warehouse_id"},
{"project_flock_populations", "product_warehouse_id"},
{"fifo_stock_v2_operation_log", "product_warehouse_id"},
{"fifo_stock_v2_reflow_checkpoints", "product_warehouse_id"},
{"fifo_stock_v2_shadow_allocations", "product_warehouse_id"},
}
for _, ref := range refTables {
res := tx.WithContext(ctx).
Table(ref.table).
Where(fmt.Sprintf("%s IN ?", ref.column), absorbedIDs).
Update(ref.column, group.SurvivorID)
if res.Error != nil {
return fmt.Errorf("update %s.%s: %w", ref.table, ref.column, res.Error)
}
if res.RowsAffected > 0 {
summary.UpdatedReferences[ref.table+"."+ref.column] += res.RowsAffected
}
}
// Update survivor qty to merged total
res := tx.WithContext(ctx).
Table("product_warehouses").
Where("id = ?", group.SurvivorID).
Update("qty", group.TotalMergedQty)
if res.Error != nil {
return fmt.Errorf("update survivor qty: %w", res.Error)
}
// Clear project_flock_kandang_id for LOKASI warehouse survivors
if err := tx.WithContext(ctx).Exec(`
UPDATE product_warehouses pw
SET project_flock_kandang_id = NULL
FROM warehouses w
WHERE pw.warehouse_id = w.id
AND pw.id = ?
AND UPPER(w.type) = 'LOKASI'
AND pw.project_flock_kandang_id IS NOT NULL
`, group.SurvivorID).Error; err != nil {
return fmt.Errorf("clear project_flock_kandang_id survivor %d: %w", group.SurvivorID, err)
}
// Delete absorbed product_warehouses
res = tx.WithContext(ctx).
Table("product_warehouses").
Where("id IN ?", absorbedIDs).
Delete(nil)
if res.Error != nil {
return fmt.Errorf("delete absorbed: %w", res.Error)
}
summary.DeletedProductWarehouses += res.RowsAffected
// Recalculate stock_logs for survivor
if err := recalculateStockLogs(ctx, tx, []uint{group.SurvivorID}); err != nil {
return fmt.Errorf("recalculate stock_logs: %w", err)
}
// Reflow and recalculate FIFO
if err := reflowProductWarehouse(ctx, fifoSvc, tx, group.SurvivorID); err != nil {
return fmt.Errorf("reflow product_warehouse %d: %w", group.SurvivorID, err)
}
}
return nil
})
if err != nil {
summary.OverallStatus = "FAIL"
return summary, err
}
return summary, nil
}
func recalculateStockLogs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) error {
if len(productWarehouseIDs) == 0 {
return nil
}
query := `
WITH recalculated AS (
SELECT
id,
SUM(COALESCE(increase, 0) - COALESCE(decrease, 0))
OVER (PARTITION BY product_warehouse_id ORDER BY created_at ASC, id ASC) AS running_stock
FROM stock_logs
WHERE product_warehouse_id IN ?
)
UPDATE stock_logs sl
SET stock = recalculated.running_stock
FROM recalculated
WHERE sl.id = recalculated.id
`
return tx.WithContext(ctx).Exec(query, productWarehouseIDs).Error
}
func reflowProductWarehouse(ctx context.Context, fifoSvc commonSvc.FifoStockV2Service, tx *gorm.DB, productWarehouseID uint) error {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var selected row
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("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 = ?", "STOCKABLE").
Where("rr.function_code = ?", "PURCHASE_IN").
Where("rr.source_table = ?", "purchase_items").
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
)`, productWarehouseID).
Order("rr.id ASC").
Limit(1).
Take(&selected).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
if err == gorm.ErrRecordNotFound {
return nil
}
flagGroupCode := strings.TrimSpace(selected.FlagGroupCode)
if _, err := fifoSvc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: productWarehouseID,
Tx: tx,
}); err != nil {
return err
}
if _, err := fifoSvc.Recalculate(ctx, commonSvc.FifoStockV2RecalculateRequest{
ProductWarehouseIDs: []uint{productWarehouseID},
FlagGroupCodes: []string{flagGroupCode},
FixDrift: true,
Tx: tx,
}); err != nil {
return err
}
return nil
}
func summarizeGroups(groups []duplicateGroup) consolidateSummary {
var totalQty int64
for _, g := range groups {
totalQty += int64(g.AbsorbedCount)
}
return consolidateSummary{
TotalDuplicateGroups: len(groups),
TotalProductWarehouses: totalQty,
OverallStatus: "PASS",
}
}
func renderConsolidation(mode string, groups []duplicateGroup, summary consolidateSummary) {
if mode == outputJSON {
payload := map[string]any{
"groups": groups,
"summary": summary,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
fmt.Fprintln(w, "AREA\tLOCATION\tWAREHOUSE\tPRODUCT\tPFK_ID\tSURVIVOR_ID\tSURVIVOR_QTY\tABSORBED_COUNT\tTOTAL_MERGED_QTY\tABSORBED_IDS")
for _, g := range groups {
pfkID := "-"
if g.ProjectFlockKandangID != nil {
pfkID = fmt.Sprintf("%d", *g.ProjectFlockKandangID)
}
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%d\t%.3f\t%d\t%.3f\t%s\n",
g.AreaName,
g.LocationName,
g.WarehouseName,
g.ProductName,
pfkID,
g.SurvivorID,
g.SurvivorQty,
g.AbsorbedCount,
g.TotalMergedQty,
g.AbsorbedIDs,
)
}
_ = w.Flush()
fmt.Printf("\n=== SUMMARY ===\n")
fmt.Printf("Duplicate groups found: %d\n", summary.TotalDuplicateGroups)
fmt.Printf("Product warehouses to delete: %d\n", summary.TotalProductWarehouses)
fmt.Printf("Overall status: %s\n", summary.OverallStatus)
if len(summary.UpdatedReferences) > 0 {
fmt.Println("\nUpdated references:")
keys := make([]string, 0, len(summary.UpdatedReferences))
for k := range summary.UpdatedReferences {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf(" %s=%d\n", k, summary.UpdatedReferences[k])
}
}
}
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
}
+387
View File
@@ -0,0 +1,387 @@
// Command: fix-stock-log-drift
//
// Tujuan:
// Sinkronkan `stock_logs.stock` (running ledger) dengan `product_warehouses.qty`
// (FIFO truth) ketika keduanya drift.
//
// Drift biasanya terjadi karena bug di Recording-Edit (sebelum fix) yang
// hanya menulis -decrease tanpa +increase saat in-place update. Akibatnya
// running ledger di stock_logs tertinggal dari qty riil di product_warehouses.
//
// Cara kerja:
// 1. Ambil product_warehouses.qty (sebagai truth)
// 2. Ambil last_stock_log.stock
// 3. Cari recording yang berkontribusi pada drift (untuk notes)
// 4. Hitung drift = qty - last_stock_log.stock
// 5. Jika drift != 0, insert 1 stock_log corrective:
// - drift > 0 → increase = drift
// - drift < 0 → decrease = |drift|
// stock akhir akan sama dengan qty (truth).
// Notes otomatis berisi daftar recording IDs yang berkontribusi pada drift.
//
// Mode:
// --apply=false (default) → dry-run, hanya tampilkan rencana
// --apply=true → eksekusi insert
//
// Contoh:
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply \
// --actor-id=1 --notes="Koreksi manual drift"
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"time"
"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"
"gorm.io/gorm"
)
const (
qtyEpsilon = 1e-6
defaultActorID uint = 1
maxSuspectInNotes = 30
)
type driftRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
WarehouseName string `gorm:"column:warehouse_name"`
CurrentQty float64 `gorm:"column:current_qty"`
LastLogStock float64 `gorm:"column:last_log_stock"`
LastLogID uint `gorm:"column:last_log_id"`
FifoExpected float64 `gorm:"column:fifo_expected"`
}
type suspectRecording struct {
RecordingID uint `gorm:"column:recording_id"`
FifoUsage float64 `gorm:"column:fifo_usage"`
NetLogConsumed float64 `gorm:"column:net_log_consumed"`
Phantom float64 `gorm:"column:phantom"`
}
func main() {
var (
productWarehouseID uint
apply bool
actorID uint
notes string
)
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Target product_warehouse_id (required)")
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.UintVar(&actorID, "actor-id", defaultActorID, "User id yang akan dicatat sebagai created_by stock_log corrective")
flag.StringVar(&notes, "notes", "", "Custom notes untuk stock_log corrective (opsional, default=auto-generate dari data recording)")
flag.Parse()
notes = strings.TrimSpace(notes)
if err := validateFlags(productWarehouseID, actorID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
row, err := loadDriftRow(ctx, db, productWarehouseID)
if err != nil {
log.Fatalf("failed to load product warehouse: %v", err)
}
suspects, err := loadSuspectRecordings(ctx, db, productWarehouseID)
if err != nil {
log.Fatalf("failed to load suspect recordings: %v", err)
}
drift := row.CurrentQty - row.LastLogStock
// Print info header
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Target product_warehouse_id: %d\n", productWarehouseID)
fmt.Printf("Product: %q\n", row.ProductName)
fmt.Printf("Warehouse: %q\n", row.WarehouseName)
fmt.Printf("Current qty (product_warehouses): %.3f\n", row.CurrentQty)
fmt.Printf("FIFO expected (sum total_qty - total_used): %.3f\n", row.FifoExpected)
fmt.Printf("Last stock_log: id=%d stock=%.3f\n", row.LastLogID, row.LastLogStock)
fmt.Printf("Drift (qty - last_log_stock): %+.3f\n", drift)
if !nearlyEqual(row.CurrentQty, row.FifoExpected) {
fmt.Println()
fmt.Println("⚠️ WARNING: product_warehouses.qty TIDAK match dengan FIFO expected.")
fmt.Println(" Disarankan jalankan dulu cmd/reflow-quantity-product-warehouse-from-stock-allocation")
fmt.Println(" sebelum fix stock_log drift, agar truth source-nya sudah benar.")
}
// Print suspect recordings
fmt.Println()
if len(suspects) > 0 {
totalPhantom := 0.0
for _, s := range suspects {
totalPhantom += s.Phantom
}
fmt.Printf("Suspect recordings (drift contributors): %d\n", len(suspects))
for _, s := range suspects {
fmt.Printf(
" #%-6d fifo=%-10.3f net_log=%-10.3f phantom=%+.3f\n",
s.RecordingID, s.FifoUsage, s.NetLogConsumed, s.Phantom,
)
}
fmt.Printf("Total suspect phantom: %+.3f\n", totalPhantom)
} else {
fmt.Println("Suspect recordings: none found (drift origin unknown)")
}
fmt.Println()
if nearlyEqual(drift, 0) {
fmt.Println("✓ Tidak ada drift. Stock_log sudah sinkron dengan product_warehouses.qty.")
fmt.Println("Summary: planned=0 inserted=0 skipped=1 failed=0")
return
}
// Build notes if not provided
if notes == "" {
notes = buildDefaultNotes(row, drift, suspects)
}
plan := buildCorrectiveLog(row, drift, actorID, notes)
fmt.Printf(
"PLAN insert stock_log:\n pw=%d increase=%.3f decrease=%.3f stock=%.3f\n notes=%q\n",
plan.ProductWarehouseId,
plan.Increase,
plan.Decrease,
plan.Stock,
plan.Notes,
)
if !apply {
fmt.Println()
fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=0 (dry-run)")
return
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Re-check di dalam transaction agar aman dari race condition
current, err := loadDriftRow(ctx, tx, productWarehouseID)
if err != nil {
return fmt.Errorf("re-read product_warehouse_id=%d: %w", productWarehouseID, err)
}
currentDrift := current.CurrentQty - current.LastLogStock
if nearlyEqual(currentDrift, 0) {
fmt.Println("Drift hilang sebelum insert (kemungkinan ada operasi paralel). Skip.")
return nil
}
fresh := buildCorrectiveLog(current, currentDrift, actorID, notes)
if err := tx.Create(&fresh).Error; err != nil {
return fmt.Errorf("insert corrective stock_log for pw=%d: %w", productWarehouseID, err)
}
fmt.Printf(
"DONE inserted stock_log id=%d pw=%d increase=%.3f decrease=%.3f stock=%.3f\n",
fresh.Id,
fresh.ProductWarehouseId,
fresh.Increase,
fresh.Decrease,
fresh.Stock,
)
return nil
}); err != nil {
fmt.Println()
fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=1")
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Println("Summary: planned=1 inserted=1 skipped=0 failed=0")
}
func validateFlags(productWarehouseID uint, actorID uint) error {
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required (must be > 0)")
}
if actorID == 0 {
return errors.New("--actor-id must be > 0")
}
return nil
}
func loadDriftRow(ctx context.Context, db *gorm.DB, productWarehouseID uint) (driftRow, error) {
row := driftRow{}
lastLogSub := db.WithContext(ctx).
Table("stock_logs").
Select("id, product_warehouse_id, stock").
Where("product_warehouse_id = ?", productWarehouseID).
Order("id DESC").
Limit(1)
fifoSub := db.WithContext(ctx).
Table("purchase_items").
Select(`
product_warehouse_id,
COALESCE(SUM(COALESCE(total_qty, 0) - COALESCE(total_used, 0)), 0) AS fifo_expected
`).
Where("product_warehouse_id = ?", productWarehouseID).
Group("product_warehouse_id")
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
COALESCE(p.name, '') AS product_name,
COALESCE(w.name, '') AS warehouse_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(last_log.stock, 0) AS last_log_stock,
COALESCE(last_log.id, 0) AS last_log_id,
COALESCE(fifo.fifo_expected, 0) AS fifo_expected
`).
Joins("LEFT JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("LEFT JOIN (?) last_log ON last_log.product_warehouse_id = pw.id", lastLogSub).
Joins("LEFT JOIN (?) fifo ON fifo.product_warehouse_id = pw.id", fifoSub).
Where("pw.id = ?", productWarehouseID).
Scan(&row).Error; err != nil {
return row, err
}
if row.ProductWarehouseID == 0 {
return row, fmt.Errorf("product_warehouse_id=%d not found", productWarehouseID)
}
return row, nil
}
// loadSuspectRecordings mencari recording yang net stock_log consumed-nya
// melebihi FIFO usage_qty — ini adalah recording yang berkontribusi pada drift
// akibat bug Recording-Edit yang hanya menulis -decrease tanpa +increase.
func loadSuspectRecordings(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]suspectRecording, error) {
rows := make([]suspectRecording, 0)
if err := db.WithContext(ctx).
Table("recording_stocks rs").
Select(`
rs.recording_id,
COALESCE(rs.usage_qty, 0) AS fifo_usage,
(
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0)
) AS net_log_consumed,
(
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) -
COALESCE(rs.usage_qty, 0)
) AS phantom
`).
Joins(`
JOIN stock_logs sl ON sl.loggable_type = ?
AND sl.loggable_id = rs.recording_id
AND sl.product_warehouse_id = rs.product_warehouse_id
`, string(utils.StockLogTypeRecording)).
Where("rs.product_warehouse_id = ?", productWarehouseID).
Group("rs.recording_id, rs.usage_qty").
Having(`
ABS(
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) -
COALESCE(rs.usage_qty, 0)
) > ?
`, qtyEpsilon).
Order("rs.recording_id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// buildDefaultNotes membuat notes otomatis yang berisi penjelasan drift
// beserta daftar recording_id yang berkontribusi + phantom amount masing-masing.
func buildDefaultNotes(row driftRow, drift float64, suspects []suspectRecording) string {
sign := "+"
if drift < 0 {
sign = ""
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf(
"Koreksi drift stock_log akibat bug Recording-Edit (in-place update menulis -decrease tanpa +increase). PW=%d (%s) drift=%s%.3f.",
row.ProductWarehouseID,
row.WarehouseName,
sign,
drift,
))
if len(suspects) == 0 {
return sb.String()
}
sb.WriteString(" Recordings affected:")
limit := len(suspects)
truncated := 0
if limit > maxSuspectInNotes {
truncated = limit - maxSuspectInNotes
limit = maxSuspectInNotes
}
for i := 0; i < limit; i++ {
s := suspects[i]
phantomSign := "+"
if s.Phantom < 0 {
phantomSign = ""
}
sb.WriteString(fmt.Sprintf(" #%d(%s%.0f)", s.RecordingID, phantomSign, s.Phantom))
if i < limit-1 || truncated > 0 {
sb.WriteString(",")
}
}
if truncated > 0 {
sb.WriteString(fmt.Sprintf(" ... (+%d more)", truncated))
}
sb.WriteString(".")
return sb.String()
}
func buildCorrectiveLog(row driftRow, drift float64, actorID uint, notes string) entity.StockLog {
corrective := entity.StockLog{
ProductWarehouseId: row.ProductWarehouseID,
CreatedBy: actorID,
LoggableType: string(utils.StockLogTypeAdjustment),
LoggableId: 0,
Stock: row.CurrentQty,
Notes: notes,
CreatedAt: time.Now(),
}
if drift > 0 {
corrective.Increase = drift
corrective.Decrease = 0
} else {
corrective.Increase = 0
corrective.Decrease = -drift
}
return corrective
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}
File diff suppressed because it is too large Load Diff
+524
View File
@@ -0,0 +1,524 @@
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" gorm:"column:case_type"`
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, args := 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 w_id, area_name, kandang_location_name, k_id AS kandang_id, name, case_type FROM (
SELECT w.id as w_id, a.name AS area_name, kl.name AS kandang_location_name, k.id as 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_a_warehouses
UNION ALL
SELECT w_id, area_name, kandang_location_name, k_id AS kandang_id, name, case_type FROM (
SELECT w.id as w_id, a.name AS area_name, kl.name AS kandang_location_name, k.id as 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
) 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.w_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, args...).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, args := 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 kl ON kl.id = fw.location_id
JOIN areas a ON a.id = kl.area_id
JOIN product_warehouses pw ON pw.warehouse_id = fw.id
JOIN products p ON p.id = pw.product_id
JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = 'products' AND UPPER(f.name) IN ('PAKAN', 'OVK')
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, args...).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"},
}
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 using raw SQL
var ids []uint
query := fmt.Sprintf("SELECT DISTINCT %s FROM %s WHERE %s IN ?",
ref.column, ref.table, ref.column)
if err := db.Raw(query, warehouseIDs).Scan(&ids).Error; err != nil {
return nil, err
}
idStrs := make([]string, 0, len(ids))
for _, id := range ids {
idStrs = append(idStrs, 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, []any) {
filters := make([]string, 0, 2)
args := make([]any, 0, 2)
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)
}
return filters, args
}
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
}
Binary file not shown.
BIN
View File
Binary file not shown.
+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
+429
View File
@@ -3215,6 +3215,55 @@
]
}
},
"/api/inventory/stock-logs/": {
"get": {
"description": "Read access to `/api/inventory/stock-logs`.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / inventory / stock logs",
"tags": [
"Inventory"
]
}
},
"/api/inventory/transfers/": {
"get": {
"description": "Read access to `/api/inventory/transfers`.",
@@ -4318,6 +4367,29 @@
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": [
{
"created_at": "2026-01-01T00:00:00Z",
"created_user": {
"id": 1,
"name": "Admin"
},
"id": 1,
"name": "FCR Broiler Standard",
"updated_at": "2026-01-01T00:00:00Z"
}
],
"message": "Get all fcrs successfully",
"meta": {
"limit": 10,
"page": 1,
"total_pages": 1,
"total_results": 1
},
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
@@ -4379,6 +4451,41 @@
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": {
"created_at": "2026-01-01T00:00:00Z",
"created_user": {
"id": 1,
"name": "Admin"
},
"fcr_standards": [
{
"fcr_number": 1.2,
"id": 1,
"mortality": 0.5,
"weight": 0.5
},
{
"fcr_number": 1.35,
"id": 2,
"mortality": 0.3,
"weight": 1
},
{
"fcr_number": 1.5,
"id": 3,
"mortality": 0.25,
"weight": 1.5
}
],
"id": 1,
"name": "FCR Broiler Standard",
"updated_at": "2026-01-01T00:00:00Z"
},
"message": "Get fcr successfully",
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
@@ -6457,6 +6564,126 @@
]
}
},
"/api/production/chickins/": {
"get": {
"description": "Read access to `/api/production/chickins`.",
"parameters": [
{
"description": "Page number.",
"example": 1,
"in": "query",
"name": "page",
"required": false,
"schema": {
"type": "integer"
}
},
{
"description": "Page size.",
"example": 10,
"in": "query",
"name": "limit",
"required": false,
"schema": {
"type": "integer"
}
},
{
"description": "Project flock kandang id filter.",
"example": 1,
"in": "query",
"name": "project_flock_kandang_id",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": [
{
"chick_in_date": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"created_user": {
"id": 1,
"name": "Admin"
},
"id": 1,
"notes": "",
"pending_usage_qty": 0,
"product_warehouse": {
"id": 1,
"product": {
"id": 1,
"name": "DOC Broiler"
},
"warehouse": {
"id": 1,
"name": "Gudang DOC"
}
},
"product_warehouse_id": 1,
"project_flock_kandang_id": 1,
"updated_at": "2026-01-01T00:00:00Z",
"usage_qty": 10000
}
],
"message": "Get all chickins successfully",
"meta": {
"limit": 10,
"page": 1,
"total_pages": 1,
"total_results": 1
},
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / production / chickins",
"tags": [
"Production"
]
}
},
"/api/production/chickins/{id}": {
"get": {
"description": "Read access to `/api/production/chickins/:id`.",
@@ -7517,6 +7744,47 @@
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": [
{
"approval": {
"action": null,
"step_name": "Pengajuan",
"step_number": 1
},
"created_at": "2026-01-15T00:00:00Z",
"created_by": 1,
"created_user": {
"id": 1,
"name": "Admin"
},
"economic_cutoff_date": "2026-01-20T00:00:00Z",
"effective_move_date": "2026-01-18T00:00:00Z",
"executed_at": null,
"from_project_flock": {
"flock_name": "Flock A Period 1",
"id": 1
},
"id": 1,
"notes": "",
"to_project_flock": {
"flock_name": "Flock B Period 1",
"id": 2
},
"transfer_date": "2026-01-15T00:00:00Z",
"transfer_number": "TL-00001"
}
],
"message": "Get all transferLayings successfully",
"meta": {
"limit": 10,
"page": 1,
"total_pages": 1,
"total_results": 1
},
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
@@ -7700,6 +7968,69 @@
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": {
"approval": {
"action": null,
"step_name": "Pengajuan",
"step_number": 1
},
"created_at": "2026-01-15T00:00:00Z",
"created_by": 1,
"created_user": {
"id": 1,
"name": "Admin"
},
"economic_cutoff_date": "2026-01-20T00:00:00Z",
"effective_move_date": "2026-01-18T00:00:00Z",
"executed_at": null,
"from_project_flock": {
"flock_name": "Flock A Period 1",
"id": 1
},
"id": 1,
"notes": "",
"sources": [
{
"note": "",
"qty": 5000,
"source_project_flock_kandang": {
"id": 1,
"kandang": {
"id": 1,
"name": "Kandang A"
},
"kandang_id": 1,
"project_flock_id": 1
}
}
],
"targets": [
{
"note": "",
"qty": 5000,
"target_project_flock_kandang": {
"id": 2,
"kandang": {
"id": 2,
"name": "Kandang B"
},
"kandang_id": 2,
"project_flock_id": 2
}
}
],
"to_project_flock": {
"flock_name": "Flock B Period 1",
"id": 2
},
"transfer_date": "2026-01-15T00:00:00Z",
"transfer_number": "TL-00001"
},
"message": "Get transferLaying successfully",
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
@@ -8912,6 +9243,55 @@
]
}
},
"/api/reports/hpp-v2-breakdown": {
"get": {
"description": "Read access to `/api/reports/hpp-v2-breakdown`.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / reports / hpp v2 breakdown",
"tags": [
"Reports"
]
}
},
"/api/reports/marketing": {
"get": {
"description": "Read access to `/api/reports/marketing`.",
@@ -9555,6 +9935,55 @@
]
}
},
"/api/system-settings/": {
"get": {
"description": "Read access to `/api/system-settings`.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / system settings",
"tags": [
"API"
]
}
},
"/api/users/": {
"get": {
"description": "Read access to `/api/users`.",
+285
View File
@@ -2006,6 +2006,34 @@ paths:
summary: GET api / inventory / product warehouses / :id
tags:
- Inventory
/api/inventory/stock-logs/:
get:
description: Read access to `/api/inventory/stock-logs`.
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / inventory / stock logs
tags:
- Inventory
/api/inventory/transfers/:
get:
description: Read access to `/api/inventory/transfers`.
@@ -2686,6 +2714,23 @@ paths:
"200":
content:
application/json:
example:
code: 200
data:
- created_at: "2026-01-01T00:00:00Z"
created_user:
id: 1
name: Admin
id: 1
name: FCR Broiler Standard
updated_at: "2026-01-01T00:00:00Z"
message: Get all fcrs successfully
meta:
limit: 10
page: 1
total_pages: 1
total_results: 1
status: success
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
@@ -2722,6 +2767,31 @@ paths:
"200":
content:
application/json:
example:
code: 200
data:
created_at: "2026-01-01T00:00:00Z"
created_user:
id: 1
name: Admin
fcr_standards:
- fcr_number: 1.2
id: 1
mortality: 0.5
weight: 0.5
- fcr_number: 1.35
id: 2
mortality: 0.3
weight: 1
- fcr_number: 1.5
id: 3
mortality: 0.25
weight: 1.5
id: 1
name: FCR Broiler Standard
updated_at: "2026-01-01T00:00:00Z"
message: Get fcr successfully
status: success
schema:
$ref: '#/components/schemas/SuccessEnvelope'
description: Successful response
@@ -3994,6 +4064,86 @@ paths:
summary: GET api / master data / warehouses / :id
tags:
- Master Data
/api/production/chickins/:
get:
description: Read access to `/api/production/chickins`.
parameters:
- description: Page number.
example: 1
in: query
name: page
required: false
schema:
type: integer
- description: Page size.
example: 10
in: query
name: limit
required: false
schema:
type: integer
- description: Project flock kandang id filter.
example: 1
in: query
name: project_flock_kandang_id
required: false
schema:
type: integer
responses:
"200":
content:
application/json:
example:
code: 200
data:
- chick_in_date: "2026-01-01T00:00:00Z"
created_at: "2026-01-01T00:00:00Z"
created_user:
id: 1
name: Admin
id: 1
notes: ""
pending_usage_qty: 0
product_warehouse:
id: 1
product:
id: 1
name: DOC Broiler
warehouse:
id: 1
name: Gudang DOC
product_warehouse_id: 1
project_flock_kandang_id: 1
updated_at: "2026-01-01T00:00:00Z"
usage_qty: 10000
message: Get all chickins successfully
meta:
limit: 10
page: 1
total_pages: 1
total_results: 1
status: success
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / production / chickins
tags:
- Production
/api/production/chickins/{id}:
get:
description: Read access to `/api/production/chickins/:id`.
@@ -4664,6 +4814,38 @@ paths:
"200":
content:
application/json:
example:
code: 200
data:
- approval:
action: null
step_name: Pengajuan
step_number: 1
created_at: "2026-01-15T00:00:00Z"
created_by: 1
created_user:
id: 1
name: Admin
economic_cutoff_date: "2026-01-20T00:00:00Z"
effective_move_date: "2026-01-18T00:00:00Z"
executed_at: null
from_project_flock:
flock_name: Flock A Period 1
id: 1
id: 1
notes: ""
to_project_flock:
flock_name: Flock B Period 1
id: 2
transfer_date: "2026-01-15T00:00:00Z"
transfer_number: TL-00001
message: Get all transferLayings successfully
meta:
limit: 10
page: 1
total_pages: 1
total_results: 1
status: success
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
@@ -4700,6 +4882,53 @@ paths:
"200":
content:
application/json:
example:
code: 200
data:
approval:
action: null
step_name: Pengajuan
step_number: 1
created_at: "2026-01-15T00:00:00Z"
created_by: 1
created_user:
id: 1
name: Admin
economic_cutoff_date: "2026-01-20T00:00:00Z"
effective_move_date: "2026-01-18T00:00:00Z"
executed_at: null
from_project_flock:
flock_name: Flock A Period 1
id: 1
id: 1
notes: ""
sources:
- note: ""
qty: 5000
source_project_flock_kandang:
id: 1
kandang:
id: 1
name: Kandang A
kandang_id: 1
project_flock_id: 1
targets:
- note: ""
qty: 5000
target_project_flock_kandang:
id: 2
kandang:
id: 2
name: Kandang B
kandang_id: 2
project_flock_id: 2
to_project_flock:
flock_name: Flock B Period 1
id: 2
transfer_date: "2026-01-15T00:00:00Z"
transfer_number: TL-00001
message: Get transferLaying successfully
status: success
schema:
$ref: '#/components/schemas/SuccessEnvelope'
description: Successful response
@@ -5545,6 +5774,34 @@ paths:
summary: GET api / reports / hpp per kandang
tags:
- Reports
/api/reports/hpp-v2-breakdown:
get:
description: Read access to `/api/reports/hpp-v2-breakdown`.
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / reports / hpp v2 breakdown
tags:
- Reports
/api/reports/marketing:
get:
description: Read access to `/api/reports/marketing`.
@@ -5955,6 +6212,34 @@ paths:
summary: GET api / sso / userinfo
tags:
- SSO
/api/system-settings/:
get:
description: Read access to `/api/system-settings`.
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / system settings
tags:
- API
/api/users/:
get:
description: Read access to `/api/users`.
+52
View File
@@ -109,6 +109,19 @@
"method": "GET",
"url": "{{base_url}}/api/closings/?page=1\u0026limit=10\u0026search=kandang\u0026project_status=1\u0026location_id={{location_id}}"
}
},
{
"name": "GET api / system settings",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/system-settings/"
}
}
],
"name": "API"
@@ -582,6 +595,19 @@
"url": "{{base_url}}/api/inventory/product-warehouses/{{id}}"
}
},
{
"name": "GET api / inventory / stock logs",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/inventory/stock-logs/"
}
},
{
"name": "GET api / inventory / transfers",
"request": {
@@ -1143,6 +1169,19 @@
},
{
"item": [
{
"name": "GET api / production / chickins",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/production/chickins/?page=1\u0026limit=10\u0026project_flock_kandang_id={{project_flock_kandang_id}}"
}
},
{
"name": "GET api / production / chickins / :id",
"request": {
@@ -1478,6 +1517,19 @@
"url": "{{base_url}}/api/reports/hpp-per-kandang?page=1\u0026limit=10\u0026period=2026-01-01\u0026show_unrecorded=false\u0026area_id=1,2\u0026location_id=1,2\u0026kandang_id=1,2\u0026weight_min=1.2\u0026weight_max=1.8"
}
},
{
"name": "GET api / reports / hpp v2 breakdown",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/reports/hpp-v2-breakdown"
}
},
{
"name": "GET api / reports / marketing",
"request": {
+1
View File
@@ -52,6 +52,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-pdf/fpdf v0.9.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
+2
View File
@@ -77,6 +77,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+1
View File
@@ -89,5 +89,6 @@ func DefaultDashboardPermissions() []string {
"lti.users.detail",
"lti.users.list",
"lti.daily_checklist.master_data.kandang",
"lti.production.chickins.list",
}
}
+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)
}
}
@@ -151,7 +151,7 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
).
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("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan).
Scan(&total).Error
@@ -202,7 +202,7 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
).
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("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error
@@ -103,6 +103,7 @@ type HppV2CostRepository interface {
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, error)
GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error)
GetRecordingStockRoutingAdjustmentCostByProjectFlockID(ctx context.Context, projectFlockID uint, periodDate time.Time) (float64, error)
GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error)
GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error)
GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error)
@@ -114,6 +115,7 @@ type HppV2CostRepository interface {
GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
GetEggProduksiBreakdownByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (recordingQty, recordingWeight, adjustmentQty, adjustmentWeight float64, err error)
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
}
@@ -249,6 +251,82 @@ func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID(
return &row, nil
}
func (r *HppV2RepositoryImpl) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(
ctx context.Context,
projectFlockID uint,
periodDate time.Time,
) (float64, error) {
if projectFlockID == 0 || periodDate.IsZero() {
return 0, nil
}
flags := []utils.FlagType{
utils.FlagPakan,
utils.FlagOVK,
utils.FlagObat,
utils.FlagVitamin,
utils.FlagKimia,
}
transferExistsCondition := `
EXISTS (
SELECT 1
FROM laying_transfer_targets AS ltt
JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id
WHERE ltt.deleted_at IS NULL
AND lt.deleted_at IS NULL
AND lt.executed_at IS NOT NULL
AND ltt.target_project_flock_kandang_id = r.project_flock_kandangs_id
AND COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) <= DATE(?)
AND (
SELECT a.action
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = lt.id
ORDER BY a.id DESC
LIMIT 1
) = ?
)
`
var total float64
err := r.db.WithContext(ctx).
Table("recording_stocks AS rs").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
Joins("JOIN project_flock_kandangs AS pfk_rec ON pfk_rec.id = r.project_flock_kandangs_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("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pfk_rec.project_flock_id = ?", projectFlockID).
Where("DATE(r.record_datetime) <= DATE(?)", periodDate).
Where(
fmt.Sprintf(
"((%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id) OR (NOT (%s) AND rs.project_flock_kandang_id IS NULL)",
transferExistsCondition,
transferExistsCondition,
),
periodDate,
string(utils.ApprovalWorkflowTransferToLaying),
entity.ApprovalActionApproved,
periodDate,
string(utils.ApprovalWorkflowTransferToLaying),
entity.ApprovalActionApproved,
).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(
ctx context.Context,
projectFlockID uint,
@@ -393,7 +471,7 @@ func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags(
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id").
Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id").
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames).
Group(`
@@ -755,7 +833,7 @@ func (r *HppV2RepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlock
).
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("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan).
Scan(&total).Error
@@ -781,58 +859,50 @@ func (r *HppV2RepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlo
}
func (r *HppV2RepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
rQty, rWeight, aQty, aWeight, err := r.GetEggProduksiBreakdownByProjectFlockKandangIds(ctx, projectFlockKandangIDs, date)
if err != nil {
return 0, 0, err
}
return rQty + aQty, rWeight + aWeight, nil
}
func (r *HppV2RepositoryImpl) GetEggProduksiBreakdownByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (recordingQty, recordingWeight, adjustmentQty, adjustmentWeight float64, err error) {
if date == nil {
now := time.Now()
date = &now
}
var totals struct {
var recordingTotals struct {
TotalPieces float64
TotalWeightKg float64
}
err := r.db.WithContext(ctx).
err = r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg").
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0) AS total_weight_kg").
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Scan(&totals).Error
Scan(&recordingTotals).Error
if err != nil {
return 0, 0, err
return 0, 0, 0, 0, err
}
var adjustmentTotals struct {
TotalQty float64
TotalWeight float64
}
adjustmentSubQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select("DISTINCT ast.id AS adjustment_id, ast.total_qty AS total_qty, 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.total_qty), 0) AS total_qty, COALESCE(SUM(adjustment_sources.price), 0) AS total_weight").
Table("adjustment_stocks AS ast").
Select("COALESCE(SUM(ast.total_qty), 0) AS total_qty, COALESCE(SUM(ast.price), 0) AS total_weight").
Joins("JOIN product_warehouses AS pw ON pw.id = ast.product_warehouse_id").
Where("pw.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("ast.function_code = ?", string(utils.AdjustmentTransactionSubtypeRecordingEggIn)).
Scan(&adjustmentTotals).Error
if err != nil {
return 0, 0, err
return 0, 0, 0, 0, err
}
totals.TotalPieces += adjustmentTotals.TotalQty
totals.TotalWeightKg += adjustmentTotals.TotalWeight
return totals.TotalPieces, totals.TotalWeightKg, nil
return recordingTotals.TotalPieces, recordingTotals.TotalWeightKg, adjustmentTotals.TotalQty, adjustmentTotals.TotalWeight, nil
}
func (r *HppV2RepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
@@ -1083,7 +1153,6 @@ CROSS JOIN lokasi_rec_totals lrt
string(utils.AdjustmentTransactionTypeRecording),
).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
@@ -2,6 +2,7 @@ package repository
import (
"context"
"fmt"
"math"
"testing"
"time"
@@ -96,6 +97,116 @@ func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(
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()
@@ -111,6 +222,12 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
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,
@@ -174,6 +291,11 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
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,
@@ -187,6 +309,26 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
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
@@ -31,7 +31,7 @@ func FlockAgeDay(originDate time.Time, periodDate time.Time) int {
if period.Before(origin) {
return 0
}
return int(period.Sub(origin).Hours()/24) + 1
return int(period.Sub(origin).Hours() / 24)
}
func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int {
@@ -18,8 +18,9 @@ type HppService interface {
}
type HppCostResponse struct {
Estimation HppCostDetail `json:"estimation"`
Real HppCostDetail `json:"real"`
Estimation HppCostDetail `json:"estimation"`
Real HppCostDetail `json:"real"`
DebugValues *HppCostDebugValues `json:"debug_values,omitempty"`
}
type HppCostDetail struct {
@@ -44,6 +44,15 @@ type HppV2Component struct {
Parts []HppV2ComponentPart `json:"parts"`
}
type HppCostDebugValues struct {
RecordingEggQty float64 `json:"recording_egg_qty"`
RecordingEggWeight float64 `json:"recording_egg_weight"`
AdjustmentEggQty float64 `json:"adjustment_egg_qty"`
AdjustmentEggWeight float64 `json:"adjustment_egg_weight"`
SoldEggQty float64 `json:"sold_egg_qty"`
SoldEggWeight float64 `json:"sold_egg_weight"`
}
type HppV2Breakdown struct {
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
ProjectFlockID uint `json:"project_flock_id"`
+114 -2
View File
@@ -16,6 +16,7 @@ const (
hppV2ComponentBopRegular = "BOP_REGULAR"
hppV2ComponentBopEksp = "BOP_EKSPEDISI"
hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST"
hppV2ComponentRecordingStockRoute = "RECORDING_STOCK_ROUTE"
hppV2ComponentDepreciation = "DEPRECIATION"
hppV2PartGrowingNormal = "growing_normal"
hppV2PartGrowingCutover = "growing_cutover"
@@ -26,6 +27,7 @@ const (
hppV2PartLayingDirect = "laying_direct"
hppV2PartLayingFarm = "laying_farm"
hppV2PartManualCutover = "manual_cutover"
hppV2PartRecordingStockRoute = "recording_stock_route"
hppV2PartDepreciationNormal = "normal_transfer"
hppV2PartDepreciationCutover = "manual_cutover"
hppV2PartDepreciationFarmSnapshot = "farm_snapshot"
@@ -34,7 +36,7 @@ const (
hppV2ProrationEggPiece = "laying_egg_piece_share"
hppV2ScopePulletCost = "pullet_cost"
hppV2ScopeProductionCost = "production_cost"
hppV2CutoverFlagPakan = "PAKAN-CUTOVER"
hppV2CutoverFlagPakan = string(utils.FlagPakan)
hppV2CutoverFlagOvk = "OVK"
)
@@ -190,6 +192,12 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t
}
appendComponent(hppV2ComponentManualPulletCost, manualPulletComponent)
recordingStockRouteComponent, err := s.getRecordingStockRouteComponent(projectFlockKandangId, contextRow, startOfDay)
if err != nil {
return nil, err
}
appendComponent(hppV2ComponentRecordingStockRoute, recordingStockRouteComponent)
depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, endOfDay, totalPulletCost)
if err != nil {
return nil, err
@@ -1064,6 +1072,100 @@ func (s *hppV2Service) getManualPulletCostComponent(
}, nil
}
func (s *hppV2Service) getRecordingStockRouteComponent(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
periodDate time.Time,
) (*HppV2Component, error) {
if s.hppRepo == nil || contextRow == nil || periodDate.IsZero() {
return nil, nil
}
farmTotalCost, err := s.hppRepo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(
context.Background(),
contextRow.ProjectFlockID,
periodDate,
)
if err != nil {
return nil, err
}
if farmTotalCost <= 0 {
return nil, nil
}
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
if len(farmPFKIDs) == 0 {
return nil, nil
}
totalPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), farmPFKIDs)
if err != nil {
return nil, err
}
if totalPopulation <= 0 {
return nil, nil
}
targetPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return nil, err
}
if targetPopulation <= 0 {
return nil, nil
}
ratio := targetPopulation / totalPopulation
if ratio <= 0 {
return nil, nil
}
appliedTotal := farmTotalCost * ratio
if appliedTotal <= 0 {
return nil, nil
}
part := HppV2ComponentPart{
Code: hppV2PartRecordingStockRoute,
Title: "Recording Stock Route",
Scopes: []string{hppV2ScopePulletCost},
Total: appliedTotal,
Proration: &HppV2Proration{
Basis: hppV2ProrationPopulation,
Numerator: targetPopulation,
Denominator: totalPopulation,
Ratio: ratio,
},
Details: map[string]any{
"period_date": formatDateOnly(periodDate),
"farm_total_cost": farmTotalCost,
"target_population": targetPopulation,
"farm_population": totalPopulation,
"project_flock_id": contextRow.ProjectFlockID,
"project_flock_kandang_id": projectFlockKandangId,
},
References: []HppV2Reference{
{
Type: "recording_stock_route",
Date: formatDateOnly(periodDate),
Qty: 1,
Total: farmTotalCost,
AppliedTotal: appliedTotal,
},
},
}
return &HppV2Component{
Code: hppV2ComponentRecordingStockRoute,
Title: "Recording Stock Route",
Scopes: []string{hppV2ScopePulletCost},
Total: appliedTotal,
Parts: []HppV2ComponentPart{part},
}, nil
}
func (s *hppV2Service) getDepreciationComponent(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
@@ -1387,7 +1489,7 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64,
return &HppCostResponse{}, nil
}
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
recordingQty, recordingWeight, adjustmentQty, adjustmentWeight, err := s.hppRepo.GetEggProduksiBreakdownByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
utils.Log.WithError(err).Errorf(
"GetHppEstimationDanRealisasi failed to get estimation egg production: project_flock_kandang_id=%d end_date=%s",
@@ -1396,6 +1498,8 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64,
)
return nil, err
}
estimPieces := recordingQty + adjustmentQty
estimWeightKg := recordingWeight + adjustmentWeight
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
if err != nil {
@@ -1449,6 +1553,14 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64,
return &HppCostResponse{
Estimation: estimation,
Real: real,
DebugValues: &HppCostDebugValues{
RecordingEggQty: recordingQty,
RecordingEggWeight: recordingWeight,
AdjustmentEggQty: adjustmentQty,
AdjustmentEggWeight: adjustmentWeight,
SoldEggQty: realPieces,
SoldEggWeight: realWeightKg,
},
}, nil
}
@@ -25,6 +25,7 @@ type hppV2RepoStub struct {
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
@@ -60,6 +61,10 @@ func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Con
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
@@ -127,6 +132,17 @@ func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(
return totalPieces, totalKg, nil
}
func (s *hppV2RepoStub) GetEggProduksiBreakdownByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, 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, 0, 0, nil
}
func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) {
if len(projectFlockKandangIDs) != 1 {
return 0, 0, nil
@@ -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,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 $$;
@@ -0,0 +1,11 @@
BEGIN;
-- Revert fcr_value and cum_depletion_rate back to NUMERIC(7,3).
-- WARNING: any value with an integer part > 9999 (e.g. high-FCR early-laying recordings)
-- will fail the cast and must be cleared first, or this rollback will error.
ALTER TABLE recordings
ALTER COLUMN fcr_value TYPE NUMERIC(7,3) USING fcr_value::NUMERIC(7,3),
ALTER COLUMN cum_depletion_rate TYPE NUMERIC(7,3) USING cum_depletion_rate::NUMERIC(7,3);
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
-- fcr_value and cum_depletion_rate were created as NUMERIC(7,3) (max integer part: 9999).
-- Early-laying flocks produce very few eggs relative to total feed consumed, so
-- FCR = usageInGrams / totalEggWeightGrams can legitimately exceed 9999 (e.g. ~31 740).
-- Widening to NUMERIC(15,3) keeps the same 3-decimal-place scale and is
-- fully backward-compatible: no existing value will be truncated or altered.
ALTER TABLE recordings
ALTER COLUMN fcr_value TYPE NUMERIC(15,3) USING fcr_value::NUMERIC(15,3),
ALTER COLUMN cum_depletion_rate TYPE NUMERIC(15,3) USING cum_depletion_rate::NUMERIC(15,3);
COMMIT;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS system_settings;
@@ -0,0 +1,11 @@
CREATE TABLE system_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO system_settings (key, value, description) VALUES
('allow_negative_pakan_ovk', 'false',
'Izinkan pencatatan penggunaan PAKAN & OVK negatif (mode migrasi): membuka semua produk PAKAN & OVK meskipun belum ada pembelian di sistem');
@@ -0,0 +1,21 @@
UPDATE recordings r
SET day = (
SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int + 1
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
)
WHERE r.deleted_at IS NULL
AND (
SELECT MIN(pc.chick_in_date)
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
) IS NOT NULL;
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 1);
@@ -0,0 +1,21 @@
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 0);
UPDATE recordings r
SET day = (
SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
)
WHERE r.deleted_at IS NULL
AND (
SELECT MIN(pc.chick_in_date)
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
) IS NOT NULL;
@@ -0,0 +1,13 @@
-- Revert chick_in_date Pullet Cikaum 1 (project_flock_kandang_id = 70) -> 23 Maret 2026
UPDATE public.project_chickins
SET chick_in_date = DATE '2026-03-23',
updated_at = NOW()
WHERE project_flock_kandang_id = 70
AND deleted_at IS NULL;
-- Revert chick_in_date Pullet Cikaum 2 (project_flock_kandang_id = 71) -> 15 Desember 2025
UPDATE public.project_chickins
SET chick_in_date = DATE '2025-12-15',
updated_at = NOW()
WHERE project_flock_kandang_id = 71
AND deleted_at IS NULL;
@@ -0,0 +1,13 @@
-- Update chick_in_date Pullet Cikaum 1 (project_flock_kandang_id = 70) -> 24 Maret 2026
UPDATE public.project_chickins
SET chick_in_date = DATE '2026-03-24',
updated_at = NOW()
WHERE project_flock_kandang_id = 70
AND deleted_at IS NULL;
-- Update chick_in_date Pullet Cikaum 2 (project_flock_kandang_id = 71) -> 6 April 2026
UPDATE public.project_chickins
SET chick_in_date = DATE '2026-04-06',
updated_at = NOW()
WHERE project_flock_kandang_id = 71
AND deleted_at IS NULL;
@@ -0,0 +1,21 @@
-- Revert: hitung ulang recording.day menggunakan chick_in_date sebelum perubahan
-- PFK 70: old chick_in_date = 2026-03-23
-- PFK 71: old chick_in_date = 2025-12-15
-- Kembalikan constraint chk_recordings_day ke >= 1
UPDATE recordings r
SET day = GREATEST(1, (r.record_datetime::date -
CASE r.project_flock_kandangs_id
WHEN 70 THEN DATE '2026-03-23'
WHEN 71 THEN DATE '2025-12-15'
END)::int + 1),
updated_at = NOW()
WHERE r.project_flock_kandangs_id IN (70, 71)
AND r.deleted_at IS NULL;
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 1);
@@ -0,0 +1,23 @@
-- Normalize recording.day untuk Pullet Cikaum 1 & 2
-- Setelah migrasi 20260505083754_update_pullet_cikaum_chick_in_date mengubah chick_in_date:
-- PFK 70: 2026-03-23 → 2026-03-24 (shift +1 hari)
-- PFK 71: 2025-12-15 → 2026-04-06 (shift +112 hari)
-- Recording.day perlu dihitung ulang: day = record_datetime::date - chick_in_date::date
-- Edge case: PFK 70 punya 1 recording (2026-03-23) sebelum chick_in_date baru → di-clamp ke 0
-- Note: constraint chk_recordings_day diubah ke >= 0 karena zero-indexed day
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 0);
UPDATE recordings r
SET day = GREATEST(0, (r.record_datetime::date - pc.chick_in_date::date)::int),
updated_at = NOW()
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
AND r.deleted_at IS NULL
AND r.project_flock_kandangs_id IN (70, 71);
@@ -0,0 +1,5 @@
-- Rollback price adjustment_stock id=531
UPDATE adjustment_stocks
SET price = 9535,
grand_total = ROUND(9000 * 9535, 3)
WHERE id = 531 AND adj_number = 'ADJ-00506';
@@ -0,0 +1,7 @@
-- Fix price adjustment_stock id=531 (ADJ-00506)
-- Old: price=9535, grand_total=85,815,000
-- New: price=12635, grand_total=113,715,000
UPDATE adjustment_stocks
SET price = 12635,
grand_total = ROUND(9000 * 12635, 3)
WHERE id = 531 AND adj_number = 'ADJ-00506';
@@ -0,0 +1 @@
ALTER TABLE expenses DROP COLUMN is_paid;
@@ -0,0 +1 @@
ALTER TABLE expenses ADD COLUMN is_paid BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,5 @@
BEGIN;
DROP TABLE IF EXISTS daily_checklist_empty_kandangs;
COMMIT;
@@ -0,0 +1,60 @@
BEGIN;
CREATE TABLE daily_checklist_empty_kandangs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
daily_checklist_id bigint NOT NULL,
kandang_id bigint NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
created_by bigint,
deleted_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT fk_dcek_daily_checklist
FOREIGN KEY (daily_checklist_id) REFERENCES daily_checklists(id) ON DELETE CASCADE,
CONSTRAINT fk_dcek_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE,
CONSTRAINT fk_dcek_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_dcek_deleted_by
FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT ck_dcek_range CHECK (end_date >= start_date)
);
CREATE INDEX idx_dcek_kandang_range
ON daily_checklist_empty_kandangs (kandang_id, start_date, end_date)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_dcek_daily_checklist_unique
ON daily_checklist_empty_kandangs (daily_checklist_id)
WHERE deleted_at IS NULL;
INSERT INTO daily_checklist_empty_kandangs (
daily_checklist_id, kandang_id, start_date, end_date, created_by, created_at, updated_at
)
SELECT
dc.id,
dc.kandang_id,
dc.date AS start_date,
COALESCE(
(SELECT (next_dc.date - INTERVAL '1 day')::date
FROM daily_checklists next_dc
WHERE next_dc.kandang_id = dc.kandang_id
AND next_dc.date > dc.date
AND next_dc.category <> 'empty_kandang'
AND (next_dc.status IS NULL OR next_dc.status <> 'REJECTED')
AND next_dc.deleted_at IS NULL
ORDER BY next_dc.date ASC
LIMIT 1),
dc.date
) AS end_date,
dc.created_by,
dc.created_at,
dc.updated_at
FROM daily_checklists dc
WHERE dc.category = 'empty_kandang'
AND dc.deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,2 @@
ALTER TABLE customers DROP COLUMN bank_name;
ALTER TABLE suppliers DROP COLUMN bank_name;
@@ -0,0 +1,2 @@
ALTER TABLE customers ADD COLUMN bank_name VARCHAR(100) NOT NULL DEFAULT '';
ALTER TABLE suppliers ADD COLUMN bank_name VARCHAR(100);
+1
View File
@@ -15,6 +15,7 @@ type Customer struct {
Phone string `gorm:"not null;size:20"`
Email string `gorm:"type:varchar(50);not null"`
AccountNumber string `gorm:"not null;size:50"`
BankName string `gorm:"not null;size:100;default:''"`
Balance float64 `gorm:"default:0"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
@@ -0,0 +1,27 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type DailyChecklistEmptyKandang struct {
Id uint `gorm:"primaryKey"`
DailyChecklistId uint `gorm:"not null"`
KandangId uint `gorm:"not null"`
StartDate time.Time `gorm:"type:date;not null"`
EndDate time.Time `gorm:"type:date;not null"`
CreatedBy *uint
DeletedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
DailyChecklist *DailyChecklist `gorm:"foreignKey:DailyChecklistId;references:Id"`
Kandang *KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
}
func (DailyChecklistEmptyKandang) TableName() string {
return "daily_checklist_empty_kandangs"
}
+15 -7
View File
@@ -1,6 +1,10 @@
package entities
import "time"
import (
"time"
"gorm.io/gorm"
)
type DailyChecklist struct {
Id uint `gorm:"primaryKey"`
@@ -14,13 +18,17 @@ type DailyChecklist struct {
DocumentPath *string
RejectReason *string
CreatedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
Kandang KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"`
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
EmptyKandang *DailyChecklistEmptyKandang `gorm:"foreignKey:DailyChecklistId;references:Id"`
}
type DailyChecklistPhase struct {
+1
View File
@@ -17,6 +17,7 @@ type Expense struct {
RealizationDate time.Time `gorm:"type:date;column:realization_date"`
TransactionDate time.Time `gorm:"type:date;not null"`
Notes string `gorm:"type:text;column:notes"`
IsPaid bool `gorm:"column:is_paid;not null;default:false"`
CreatedBy uint64 `gorm:""`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -6,6 +6,7 @@ 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"`
+1
View File
@@ -43,6 +43,7 @@ type Recording struct {
StandardEggMass *float64 `gorm:"-"`
StandardEggWeight *float64 `gorm:"-"`
StandardFcr *float64 `gorm:"-"`
StandardWeek *int `gorm:"-"`
PopulationCanChange *bool `gorm:"-"`
TransferExecuted *bool `gorm:"-"`
IsTransition *bool `gorm:"-"`
+6 -5
View File
@@ -1,11 +1,12 @@
package entities
type RecordingStock struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
UsageQty *float64 `gorm:"column:usage_qty"`
PendingQty *float64 `gorm:"column:pending_qty"`
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id;index"`
UsageQty *float64 `gorm:"column:usage_qty"`
PendingQty *float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
+1
View File
@@ -19,6 +19,7 @@ type Supplier struct {
Address string `gorm:"not null"`
Npwp *string `gorm:"size:50"`
AccountNumber *string `gorm:"size:50"`
BankName *string `gorm:"size:100"`
Balance float64 `gorm:"type:numeric(15,3);default:0"`
DueDate int `gorm:"not null"`
CreatedBy uint `gorm:"not null"`
+17
View File
@@ -0,0 +1,17 @@
package entities
import "time"
const SystemSettingKeyAllowNegativePakanOVK = "allow_negative_pakan_ovk"
type SystemSetting struct {
Key string `gorm:"column:key;primaryKey" json:"key"`
Value string `gorm:"column:value;not null;default:''" json:"value"`
Description string `gorm:"column:description" json:"description"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
}
func (SystemSetting) TableName() string {
return "system_settings"
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
type Warehouse struct {
Id uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(50);not null"`
Type string `gorm:"not null"`
Type string `gorm:"column:type;not null"`
AreaId uint `gorm:"not null"`
LocationId *uint
KandangId *uint
+6
View File
@@ -4,6 +4,10 @@ const (
P_DashboardGetAll = "lti.dashboard.list"
)
const (
P_SystemSettingUpdate = "lti.system_settings.update"
)
// project-flock
const (
P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing"
@@ -62,6 +66,7 @@ const (
P_ProductStockGetOne = "lti.inventory.product_stock.detail"
P_ProductWarehousekGetAll = "lti.inventory.product_warehouses.list"
P_ProductWarehouseGetOne = "lti.inventory.product_warehouses.detail"
P_StockLogGetAll = "lti.inventory.stock_log.list"
)
const (
P_ClosingGetAll = "lti.closing.list"
@@ -203,6 +208,7 @@ const (
P_Finances_Transaction_DeleteOne = "lti.finance.transactions.delete"
)
const (
P_ChickinsGetAll = "lti.production.chickins.list"
P_ChickinsCreateOne = "lti.production.chickins.create"
P_ChickinsGetOne = "lti.production.chickins.detail"
P_ChickinsApproval = "lti.production.chickins.approve"
@@ -153,6 +153,20 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
return normalized
}
normalizeCutOverToken := func(raw string) string {
normalized := strings.ToUpper(strings.TrimSpace(raw))
normalized = strings.ReplaceAll(normalized, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "")
return normalized
}
containsCutOver := func(values ...string) bool {
for _, value := range values {
if strings.Contains(normalizeCutOverToken(value), "CUTOVER") {
return true
}
}
return false
}
filter := normalizeFlag(flag)
byFlag := map[string]**SapronakCategoryDTO{}
@@ -258,6 +272,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
UnitPrice: item.Harga,
Notes: "-",
}
isCutOver := containsCutOver(baseRow.ProductCategory, baseRow.Description, item.ProductName)
row := getOrCreateRow(productKey, baseRow)
@@ -289,11 +304,21 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price
case "adjustment keluar", "mutasi keluar", "penjualan":
case "adjustment keluar":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
if row.UnitPrice == 0 {
row.UnitPrice = price
}
if isCutOver {
row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price
continue
}
row.QtyOut += item.QtyKeluar
case "mutasi keluar", "penjualan":
row.QtyOut += item.QtyKeluar
if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" {
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
@@ -0,0 +1,124 @@
package dto
import "testing"
func TestToSapronakProjectAggregatedFromReportMovesCutOverAdjustmentOutToQtyUsed(t *testing.T) {
tests := []struct {
name string
groupFlag string
filter string
productFlag string
}{
{
name: "pakan cut-over",
groupFlag: "PAKAN",
filter: "PAKAN",
productFlag: "PAKAN CUT-OVER",
},
{
name: "ovk cut over",
groupFlag: "OVK",
filter: "OVK",
productFlag: "OVK CUT OVER",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
report := &SapronakReportDTO{
Groups: []SapronakGroupDTO{
{
Flag: tc.groupFlag,
Items: []SapronakDetailDTO{
{
ProductID: 1,
ProductName: "CUTOVER ITEM",
NoReferensi: "ADJ-CUT-01",
JenisTransaksi: "Adjustment Keluar",
QtyKeluar: 5,
Harga: 15000,
},
},
},
},
}
result := ToSapronakProjectAggregatedFromReport(
report,
tc.filter,
map[uint][]string{
1: {tc.productFlag},
},
)
var cat *SapronakCategoryDTO
if tc.groupFlag == "PAKAN" {
cat = result.Pakan
} else {
cat = result.Ovk
}
if cat == nil {
t.Fatalf("expected category payload for %s", tc.groupFlag)
}
if len(cat.Rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(cat.Rows))
}
row := cat.Rows[0]
if row.QtyOut != 0 {
t.Fatalf("expected qty_out 0 for cut-over adjustment, got %.2f", row.QtyOut)
}
if row.QtyUsed != 5 {
t.Fatalf("expected qty_used 5 for cut-over adjustment, got %.2f", row.QtyUsed)
}
if row.UnitPrice != 15000 {
t.Fatalf("expected unit_price 15000, got %.2f", row.UnitPrice)
}
if row.TotalAmount != 75000 {
t.Fatalf("expected total_amount 75000, got %.2f", row.TotalAmount)
}
})
}
}
func TestToSapronakProjectAggregatedFromReportKeepsNonCutOverAdjustmentOutInQtyOut(t *testing.T) {
report := &SapronakReportDTO{
Groups: []SapronakGroupDTO{
{
Flag: "PAKAN",
Items: []SapronakDetailDTO{
{
ProductID: 7,
ProductName: "PAKAN REGULER",
NoReferensi: "ADJ-REG-01",
JenisTransaksi: "Adjustment Keluar",
QtyKeluar: 3,
Harga: 12000,
},
},
},
},
}
result := ToSapronakProjectAggregatedFromReport(
report,
"PAKAN",
map[uint][]string{
7: {"PAKAN"},
},
)
if result.Pakan == nil || len(result.Pakan.Rows) != 1 {
t.Fatalf("expected 1 pakan row, got %+v", result.Pakan)
}
row := result.Pakan.Rows[0]
if row.QtyOut != 3 {
t.Fatalf("expected qty_out 3 for non cut-over adjustment, got %.2f", row.QtyOut)
}
if row.QtyUsed != 0 {
t.Fatalf("expected qty_used 0 for non cut-over adjustment, got %.2f", row.QtyUsed)
}
if row.TotalAmount != 0 {
t.Fatalf("expected total_amount 0 for non cut-over adjustment, got %.2f", row.TotalAmount)
}
}
@@ -98,6 +98,9 @@ func sapronakIncomingPurchaseQueryParts(params SapronakQueryParams) (string, []a
fifo.StockableKeyPurchaseItems.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.UsableKeyRecordingStock.String(),
params.ProjectFlockKandangIDs,
fifo.UsableKeyProjectChickin.String(),
params.ProjectFlockKandangIDs,
params.ProjectFlockKandangIDs,
params.WarehouseIDs,
@@ -323,7 +326,7 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c
Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
Where("rec.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("f.name = ?", "PAKAN").
Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used").
Scan(&usageAgg).Error
@@ -905,7 +908,11 @@ WITH scoped_farm_allocations AS (
WHERE sa.stockable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) IN ?
AND (
(sa.usable_type = ? AND rs.project_flock_kandang_id IN ?)
OR
(sa.usable_type = ? AND pc.project_flock_kandang_id IN ?)
)
GROUP BY sa.stockable_id
)
SELECT
@@ -1167,7 +1174,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui
"recording_stocks rs",
"pw.id = rs.product_warehouse_id",
[]string{"JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"},
"r.project_flock_kandangs_id = ? AND f.name IN ?",
"rs.project_flock_kandang_id = ? AND f.name IN ?",
pfkID,
sapronakFlagsUsage,
)
@@ -1208,7 +1215,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p
COALESCE(rs.usage_qty,0) AS qty_out,
COALESCE(p.product_price,0) AS price
`,
"r.project_flock_kandangs_id = ? AND f.name IN ?",
"rs.project_flock_kandang_id = ? AND f.name IN ?",
pfkID,
sapronakFlagsUsage,
)
@@ -1294,7 +1301,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll).
Where(`
(sa.usable_type = ? AND r.project_flock_kandangs_id = ? AND f.name IN ?)
(sa.usable_type = ? AND rs.project_flock_kandang_id = ? AND f.name IN ?)
OR
(sa.usable_type = ? AND pc_used.project_flock_kandang_id = ? AND f.name IN ?)
`,
@@ -1347,7 +1354,12 @@ func (r *ClosingRepositoryImpl) incomingFarmPurchaseAllocationBase(ctx context.C
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id IS NULL").
Where("COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) = ?", projectFlockKandangID).
Where("(sa.usable_type = ? AND rs.project_flock_kandang_id = ?) OR (sa.usable_type = ? AND pc.project_flock_kandang_id = ?)",
fifo.UsableKeyRecordingStock.String(),
projectFlockKandangID,
fifo.UsableKeyProjectChickin.String(),
projectFlockKandangID,
).
Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL")
db = applyDateRange(db, "pi.received_date", start, end)
@@ -1576,11 +1588,20 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
poByWarehouse := r.DB().
Table("purchase_items pi").
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date").
Joins("JOIN purchases po ON po.id = pi.purchase_id").
Where("pi.received_date IS NOT NULL").
Order("pi.product_warehouse_id, pi.received_date ASC")
Table("(?) AS ranked_po",
r.DB().
Table("purchase_items pi").
Select(`
pi.product_warehouse_id,
po.po_number,
pi.received_date,
ROW_NUMBER() OVER (PARTITION BY pi.product_warehouse_id ORDER BY pi.received_date ASC, pi.id ASC) AS rn
`).
Joins("JOIN purchases po ON po.id = pi.purchase_id").
Where("pi.received_date IS NOT NULL"),
).
Select("ranked_po.product_warehouse_id, ranked_po.po_number, ranked_po.received_date").
Where("ranked_po.rn = 1")
incomingQuery := r.withCtx(ctx).
Table("adjustment_stocks AS ast").
@@ -1589,10 +1610,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
p.name AS product_name,
f.name AS flag,
ast.created_at AS date,
CONCAT('ADJ-', ast.id) AS reference,
'ADJ-' || CAST(ast.id AS TEXT) AS reference,
COALESCE(ast.total_qty, 0) AS qty_in,
0 AS qty_out,
COALESCE(p.product_price, 0) AS price
COALESCE(ast.price, p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
@@ -1615,10 +1636,18 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
p.name AS product_name,
f.name AS flag,
COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date,
COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference,
COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
pfp_po.po_number,
CASE WHEN ast_in.id IS NOT NULL THEN 'ADJ-' || CAST(ast_in.id AS TEXT) END,
CASE WHEN ast.id IS NOT NULL THEN 'ADJ-' || CAST(ast.id AS TEXT) END,
CASE WHEN pc.id IS NOT NULL THEN 'CHICKIN-' || CAST(pc.id AS TEXT) END
) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price
COALESCE(pi.price, ast_in.price, ast.price, p.product_price, 0) AS price
`).
Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
@@ -1639,7 +1668,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price")
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, pi.price, ast_in.price, ast.price, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoingQuery = applyDateRange(outgoingQuery, dateExpr, start, end)
outgoing, err := scanAndGroupDetails(outgoingQuery)
@@ -20,8 +20,8 @@ func TestSapronakIncomingPurchaseQueryPartsUsesAttributedPurchasesWhenProjectFlo
if sql != sapronakIncomingPurchasesScopedSQL() {
t.Fatalf("expected scoped purchase SQL, got %q", sql)
}
if len(args) != 8 {
t.Fatalf("expected 8 argument groups, got %d", len(args))
if len(args) != 11 {
t.Fatalf("expected 11 argument groups, got %d", len(args))
}
}
@@ -42,7 +42,7 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho
(2, 10, 'products', 'OBAT')`,
`INSERT INTO purchases (id, po_number, deleted_at) VALUES (1, 'PO-LTI-0005', NULL)`,
`INSERT INTO recordings (id, project_flock_kandangs_id, deleted_at) VALUES (11, 101, NULL), (12, 999, NULL)`,
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty) VALUES (21, 11, 501, 150), (22, 12, 502, 10)`,
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty, project_flock_kandang_id) VALUES (21, 11, 501, 150, 101), (22, 12, 502, 10, 999)`,
`INSERT INTO purchase_items (id, purchase_id, product_id, warehouse_id, project_flock_kandang_id, total_qty, price, received_date) VALUES
(1, 1, 10, 1, NULL, 100, 261700, '` + receivedAt.Format(time.RFC3339) + `'),
(2, 1, 20, 1, NULL, 50, 15000, '` + receivedAt.Format(time.RFC3339) + `'),
@@ -89,6 +89,63 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho
}
}
func TestFetchSapronakAdjustmentsUsesAdjustmentReferenceAndPrice(t *testing.T) {
db := setupClosingRepositoryTestDB(t)
repo := NewClosingRepository(db)
ctx := context.Background()
statements := []string{
`INSERT INTO warehouses (id, kandang_id) VALUES (5, 5)`,
`INSERT INTO product_categories (id, code) VALUES (1, 'OBT')`,
`INSERT INTO products (id, name, product_category_id, product_price) VALUES (17, 'OVK CUT-OVER', 1, 1)`,
`INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES (1, 17, 'products', 'OVK')`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id) VALUES (1365, 17, 5, 66)`,
`INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, usage_qty, price) VALUES
(1139, 1365, 1, 0, 298594487),
(1140, 1365, 0, 1, 298594487)`,
fmt.Sprintf(`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, allocation_purpose, status) VALUES
(25990, 1365, '%s', 1139, '%s', 1140, 1, 'CONSUME', 'ACTIVE')`,
fifo.StockableKeyAdjustmentIn.String(),
fifo.UsableKeyAdjustmentOut.String(),
),
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding schema: %v", err)
}
}
incoming, outgoing, err := repo.FetchSapronakAdjustments(ctx, 5, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
incomingRows := incoming[17]
if len(incomingRows) != 1 {
t.Fatalf("expected 1 incoming row for product 17, got %d", len(incomingRows))
}
if incomingRows[0].Reference != "ADJ-1139" {
t.Fatalf("expected incoming reference ADJ-1139, got %q", incomingRows[0].Reference)
}
if incomingRows[0].Price != 298594487 {
t.Fatalf("expected incoming price 298594487 from adjustment_stocks.price, got %.3f", incomingRows[0].Price)
}
outgoingRows := outgoing[17]
if len(outgoingRows) != 1 {
t.Fatalf("expected 1 outgoing row for product 17, got %d", len(outgoingRows))
}
if outgoingRows[0].Reference != "ADJ-1139" {
t.Fatalf("expected outgoing reference ADJ-1139, got %q", outgoingRows[0].Reference)
}
if outgoingRows[0].Reference == "CHICKIN-" {
t.Fatalf("expected outgoing reference to avoid CHICKIN- placeholder")
}
if outgoingRows[0].Price != 298594487 {
t.Fatalf("expected outgoing price 298594487 from adjustment_stocks.price, got %.3f", outgoingRows[0].Price)
}
}
func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
@@ -134,6 +191,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
purchase_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
product_warehouse_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
@@ -145,14 +203,21 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE recording_stocks (
id INTEGER PRIMARY KEY,
recording_id INTEGER NOT NULL,
product_warehouse_id INTEGER NOT NULL,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0
)`,
id INTEGER PRIMARY KEY,
recording_id INTEGER NOT NULL,
product_warehouse_id INTEGER NOT NULL,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL
project_flock_kandang_id INTEGER NOT NULL,
chick_in_date TIMESTAMP NULL
)`,
`CREATE TABLE project_flock_populations (
id INTEGER PRIMARY KEY,
project_chickin_id INTEGER NULL,
product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
@@ -179,6 +244,16 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
movement_number TEXT NULL,
reason TEXT NULL
)`,
`CREATE TABLE laying_transfers (
id INTEGER PRIMARY KEY,
transfer_date TIMESTAMP NULL,
transfer_number TEXT NULL
)`,
`CREATE TABLE laying_transfer_targets (
id INTEGER PRIMARY KEY,
laying_transfer_id INTEGER NOT NULL,
product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY,
stock_transfer_id INTEGER NOT NULL,
@@ -193,6 +268,7 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
product_warehouse_id INTEGER NOT NULL,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
adj_number TEXT NULL,
created_at TIMESTAMP NULL
)`,
@@ -573,17 +573,21 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return nil, nil, err
}
var minChickin *time.Time
var firstChickin struct {
ChickInDate time.Time `gorm:"column:chick_in_date"`
}
if err := db.Table("project_chickins").
Select("MIN(chick_in_date)").
Where("project_flock_kandang_id = ?", pfk.Id).
Scan(&minChickin).Error; err != nil {
Select("chick_in_date").
Where("project_flock_kandang_id = ? AND chick_in_date IS NOT NULL", pfk.Id).
Order("chick_in_date ASC").
Limit(1).
Scan(&firstChickin).Error; err != nil {
return nil, nil, err
}
start := pfk.CreatedAt
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
if !firstChickin.ChickInDate.IsZero() {
start = firstChickin.ChickInDate
}
startDate := dateOnlyUTC(start)
@@ -596,26 +600,34 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return &startDate, endDate, nil
}
var minCreated time.Time
var firstPFK entity.ProjectFlockKandang
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MIN(created_at)").
Select("created_at").
Where("project_flock_id = ?", projectFlockID).
Scan(&minCreated).Error; err != nil {
Order("created_at ASC").
First(&firstPFK).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil
}
return nil, nil, err
}
var minChickin *time.Time
var firstChickin struct {
ChickInDate time.Time `gorm:"column:chick_in_date"`
}
if err := db.Table("project_chickins pc").
Select("MIN(pc.chick_in_date)").
Select("pc.chick_in_date").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id").
Where("pfk.project_flock_id = ?", projectFlockID).
Scan(&minChickin).Error; err != nil {
Where("pfk.project_flock_id = ? AND pc.chick_in_date IS NOT NULL", projectFlockID).
Order("pc.chick_in_date ASC").
Limit(1).
Scan(&firstChickin).Error; err != nil {
return nil, nil, err
}
start := minCreated
if minChickin != nil && !minChickin.IsZero() {
start = *minChickin
start := firstPFK.CreatedAt
if !firstChickin.ChickInDate.IsZero() {
start = firstChickin.ChickInDate
}
startDate := dateOnlyUTC(start)
@@ -627,15 +639,19 @@ func (s closingService) getSapronakDateRange(ctx context.Context, projectFlockID
return nil, nil, err
}
if openCount == 0 {
var maxClosed *time.Time
var latestClosed entity.ProjectFlockKandang
if err := db.Model(&entity.ProjectFlockKandang{}).
Select("MAX(closed_at)").
Where("project_flock_id = ?", projectFlockID).
Scan(&maxClosed).Error; err != nil {
Select("closed_at").
Where("project_flock_id = ? AND closed_at IS NOT NULL", projectFlockID).
Order("closed_at DESC").
First(&latestClosed).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &startDate, nil, nil
}
return nil, nil, err
}
if maxClosed != nil && !maxClosed.IsZero() {
d := dateOnlyUTC(*maxClosed)
if latestClosed.ClosedAt != nil && !latestClosed.ClosedAt.IsZero() {
d := dateOnlyUTC(*latestClosed.ClosedAt)
endDate = &d
}
}
@@ -0,0 +1,94 @@
package service
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
"gorm.io/gorm"
)
func TestGetSapronakDateRange_ProjectWithoutChickin_DoesNotError(t *testing.T) {
db := setupClosingServiceTestDB(t)
repo := repository.NewClosingRepository(db)
svc := closingService{Repository: repo}
createdAt := time.Date(2026, 4, 15, 7, 0, 0, 0, time.UTC)
if err := db.Exec(`INSERT INTO project_flock_kandangs (id, project_flock_id, created_at, closed_at) VALUES (66, 47, ?, NULL)`, createdAt).Error; err != nil {
t.Fatalf("failed seeding project_flock_kandangs: %v", err)
}
start, end, err := svc.getSapronakDateRange(context.Background(), 47, nil)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if start == nil {
t.Fatalf("expected non-nil start date")
}
expected := dateOnlyUTC(createdAt)
if !start.Equal(expected) {
t.Fatalf("expected start %s, got %s", expected.Format(time.RFC3339), start.Format(time.RFC3339))
}
if end != nil {
t.Fatalf("expected nil end date for open kandang, got %v", end)
}
}
func TestGetSapronakDateRange_KandangWithoutChickin_DoesNotError(t *testing.T) {
db := setupClosingServiceTestDB(t)
repo := repository.NewClosingRepository(db)
svc := closingService{Repository: repo}
createdAt := time.Date(2026, 4, 15, 7, 0, 0, 0, time.UTC)
if err := db.Exec(`INSERT INTO project_flock_kandangs (id, project_flock_id, created_at, closed_at) VALUES (66, 47, ?, NULL)`, createdAt).Error; err != nil {
t.Fatalf("failed seeding project_flock_kandangs: %v", err)
}
pfkID := uint(66)
start, end, err := svc.getSapronakDateRange(context.Background(), 47, &pfkID)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if start == nil {
t.Fatalf("expected non-nil start date")
}
expected := dateOnlyUTC(createdAt)
if !start.Equal(expected) {
t.Fatalf("expected start %s, got %s", expected.Format(time.RFC3339), start.Format(time.RFC3339))
}
if end != nil {
t.Fatalf("expected nil end date for open kandang, got %v", end)
}
}
func setupClosingServiceTestDB(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)
}
stmts := []string{
`CREATE TABLE project_flock_kandangs (
id INTEGER PRIMARY KEY,
project_flock_id INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
closed_at TIMESTAMP NULL
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL,
chick_in_date TIMESTAMP NULL
)`,
}
for _, stmt := range stmts {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -371,7 +371,9 @@ func buildSapronakDetails(
addRows(result.Incoming, incomingRows, "Pembelian", true)
addRows(result.Usage, usageRows, "Pemakaian", false)
addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true)
addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false)
// Outgoing adjustment rows here are sourced from stock allocation
// consume flow (adjustment_stocks.usage_qty), so treat them as usage.
addRows(result.AdjOutgoing, adjOutgoingRows, "Pemakaian", false)
addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true)
addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false)
addRows(result.SalesOut, salesOutRows, "Penjualan", false)
@@ -0,0 +1,45 @@
package service
import (
"testing"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
)
func TestBuildSapronakDetailsMapsAdjustmentOutgoingAsUsage(t *testing.T) {
res := buildSapronakDetails(
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{
17: {
{
ProductID: 17,
ProductName: "PAKAN GROWING CRUMBLE 8603 MALINDO",
Flag: "PAKAN",
QtyOut: 9000,
Price: 6450,
},
},
},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
map[uint][]repository.SapronakDetailRow{},
)
rows := res.AdjOutgoing[17]
if len(rows) != 1 {
t.Fatalf("expected 1 adjustment outgoing row, got %d", len(rows))
}
row := rows[0]
if row.JenisTransaksi != "Pemakaian" {
t.Fatalf("expected jenis_transaksi Pemakaian, got %q", row.JenisTransaksi)
}
if row.QtyKeluar != 9000 {
t.Fatalf("expected qty_keluar 9000, got %.3f", row.QtyKeluar)
}
if row.Nilai != 58050000 {
t.Fatalf("expected nilai 58050000, got %.3f", row.Nilai)
}
}
@@ -10,7 +10,7 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
ProjectStatus *int `query:"project_status" validate:"omitempty,oneof=1 2"`
LocationID *uint `query:"location_id" validate:"omitempty,gt=0"`
@@ -24,7 +24,7 @@ const (
type ClosingSapronakQuery struct {
Type string `query:"type" validate:"required,oneof=incoming outgoing"`
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"`
KandangID *uint `query:"kandang_id" validate:"omitempty,gt=0"`
Search string `query:"search" validate:"omitempty,max=100"`
}
@@ -238,17 +238,17 @@ func (u *DailyChecklistController) GetReport(c *fiber.Ctx) error {
}
result, totalResults, err := u.DailyChecklistService.GetReport(c, query)
withoutActivities := func(src map[string]int) map[string]int {
if src == nil {
return map[string]int{}
}
return src
}
if err != nil {
return err
}
withoutActivities := func(src map[string]any) map[string]any {
if src == nil {
return map[string]any{}
}
return src
}
responseData := make([]dto.DailyChecklistReportDTO, len(result))
for i, item := range result {
responseData[i] = dto.DailyChecklistReportDTO{
@@ -351,6 +351,31 @@ func (u *DailyChecklistController) CreateOne(c *fiber.Ctx) error {
})
}
func (u *DailyChecklistController) BulkUpdate(c *fiber.Ctx) error {
req := new(validation.BulkStatusUpdate)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
results, err := u.DailyChecklistService.BulkUpdate(c, req)
if err != nil {
return err
}
responseData := make([]dto.DailyChecklistListDTO, len(results))
for i, item := range results {
responseData[i] = dto.ToDailyChecklistListDTO(item)
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Bulk update dailyChecklist successfully",
Data: responseData,
})
}
func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("idDailyChecklist")
@@ -387,6 +412,33 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
})
}
func (u *DailyChecklistController) UpdateByPut(c *fiber.Ctx) error {
req := new(validation.Create)
param := c.Params("idDailyChecklist")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.DailyChecklistService.UpdateByPut(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update dailyChecklist successfully",
Data: dto.ToDailyChecklistListDTO(*result),
})
}
func validateDailyChecklistDocumentSizes(files []*multipart.FileHeader) error {
const maxDailyChecklistDocumentBytes = 5 * 1024 * 1024 // 5MB
for _, file := range files {
@@ -42,6 +42,13 @@ type DailyChecklistDetailDTO struct {
TotalActivity int `json:"total_activity"`
Progress float64 `json:"progress"`
DocumentURLs []DailyChecklistDocumentDTO `json:"document_urls"`
EmptyKandang *DailyChecklistEmptyKandangDTO `json:"empty_kandang,omitempty"`
}
type DailyChecklistEmptyKandangDTO struct {
Id uint `json:"id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
}
type DailyChecklistDocumentDTO struct {
@@ -72,13 +79,14 @@ type DailyChecklistPerformanceOverviewDTO struct {
ActivityLeft int `json:"activity_left"`
}
type DailyChecklistReportDTO struct {
Area DailyChecklistReportEntityDTO `json:"area"`
Farm DailyChecklistReportEntityDTO `json:"farm"`
Kandang DailyChecklistReportEntityDTO `json:"kandang"`
ABK DailyChecklistReportEntityDTO `json:"abk"`
Phase string `json:"phase"`
DailyActivities map[string]int `json:"daily_activities"`
DailyActivities map[string]any `json:"daily_activities"`
Summary DailyChecklistReportSummaryDTO `json:"summary"`
}
@@ -179,6 +187,17 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO {
}
}
func ToDailyChecklistEmptyKandangDTO(e *entity.DailyChecklistEmptyKandang) *DailyChecklistEmptyKandangDTO {
if e == nil || e.Id == 0 {
return nil
}
return &DailyChecklistEmptyKandangDTO{
Id: e.Id,
StartDate: e.StartDate,
EndDate: e.EndDate,
}
}
func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64, documentURLs []DailyChecklistDocumentDTO) DailyChecklistDetailDTO {
phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases))
for _, phase := range phases {
@@ -240,5 +259,6 @@ func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.
TotalActivity: totalActivities,
Progress: progress,
DocumentURLs: documentURLs,
EmptyKandang: ToDailyChecklistEmptyKandangDTO(checklist.EmptyKandang),
}
}
+2 -1
View File
@@ -22,6 +22,7 @@ type DailyChecklistModule struct{}
func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db)
emptyKandangRepo := rDailyChecklist.NewDailyChecklistEmptyKandangRepository(db)
phasesRepo := rPhases.NewPhasesRepository(db)
userRepo := rUser.NewUserRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
@@ -30,7 +31,7 @@ func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
panic(fmt.Sprintf("failed to create document service: %v", err))
}
dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate, documentSvc)
dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, emptyKandangRepo, phasesRepo, validate, documentSvc)
userService := sUser.NewUserService(userRepo, validate)
DailyChecklistRoutes(router, userService, dailyChecklistService)
@@ -0,0 +1,98 @@
package repository
import (
"context"
"errors"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type DailyChecklistEmptyKandangRepository interface {
repository.BaseRepository[entity.DailyChecklistEmptyKandang]
FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error)
FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error)
FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error)
FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error)
SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error
}
type DailyChecklistEmptyKandangRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.DailyChecklistEmptyKandang]
}
func NewDailyChecklistEmptyKandangRepository(db *gorm.DB) DailyChecklistEmptyKandangRepository {
return &DailyChecklistEmptyKandangRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklistEmptyKandang](db),
}
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("daily_checklist_id = ?", dailyChecklistID).
First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
query := r.DB().WithContext(ctx).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate)
if excludeDailyChecklistID > 0 {
query = query.Where("daily_checklist_id <> ?", excludeDailyChecklistID)
}
if err := query.First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date).
First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error) {
if len(kandangIDs) == 0 {
return []entity.DailyChecklistEmptyKandang{}, nil
}
var recs []entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?", kandangIDs, rangeEnd, rangeStart).
Find(&recs).Error; err != nil {
return nil, err
}
return recs, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error {
updates := map[string]any{
"deleted_at": time.Now(),
}
if actorID != nil {
updates["deleted_by"] = *actorID
}
return r.DB().WithContext(ctx).
Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", dailyChecklistID).
Updates(updates).Error
}
@@ -1,13 +1,22 @@
package repository
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"context"
"time"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"github.com/gofiber/fiber/v2"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type DailyChecklistRepository interface {
repository.BaseRepository[entity.DailyChecklist]
ListScopedChecklistIDs(c *fiber.Ctx, ids []uint) ([]uint, error)
BulkUpdateStatus(ctx context.Context, ids []uint, status string, rejectReason *string) error
ListByIDsWithKandang(ctx context.Context, ids []uint) ([]entity.DailyChecklist, error)
}
type DailyChecklistRepositoryImpl struct {
@@ -19,3 +28,71 @@ func NewDailyChecklistRepository(db *gorm.DB) DailyChecklistRepository {
BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklist](db),
}
}
func (r *DailyChecklistRepositoryImpl) ListScopedChecklistIDs(c *fiber.Ctx, ids []uint) ([]uint, error) {
if len(ids) == 0 {
return []uint{}, nil
}
db := r.DB().WithContext(c.Context()).
Table("daily_checklists dc").
Select("dc.id").
Joins("JOIN kandang_groups k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id IN ?", ids).
Where("dc.deleted_at IS NULL")
db, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return nil, err
}
var scopedIDs []uint
if err := db.Pluck("dc.id", &scopedIDs).Error; err != nil {
return nil, err
}
return scopedIDs, nil
}
func (r *DailyChecklistRepositoryImpl) BulkUpdateStatus(ctx context.Context, ids []uint, status string, rejectReason *string) error {
if len(ids) == 0 {
return nil
}
updateBody := map[string]any{
"status": status,
"reject_reason": rejectReason,
"updated_at": time.Now(),
}
return r.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&entity.DailyChecklist{}).
Where("id IN ?", ids).
Updates(updateBody)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != int64(len(ids)) {
return gorm.ErrRecordNotFound
}
return nil
})
}
func (r *DailyChecklistRepositoryImpl) ListByIDsWithKandang(ctx context.Context, ids []uint) ([]entity.DailyChecklist, error) {
if len(ids) == 0 {
return []entity.DailyChecklist{}, nil
}
var items []entity.DailyChecklist
if err := r.DB().WithContext(ctx).
Where("id IN ?", ids).
Preload("Kandang").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
@@ -58,6 +58,8 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist.
*/
route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment)
route.Patch("/bulk-update", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.BulkUpdate)
route.Put("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateByPut)
route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne)
route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,432 @@
package service
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations"
"gorm.io/gorm"
)
func TestCreateOneRejectsWhenSameDateHasActiveEmptyKandang(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr("DRAFT"), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-10",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
})
if result != nil {
t.Fatalf("expected nil result, got %+v", result)
}
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
if resp.StatusCode != fiber.StatusConflict {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
}
}
func TestCreateOneRejectsWhenSameDateHasRejectedEmptyKandang(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr(dailyChecklistStatusRejected), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-10",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
})
if result != nil {
t.Fatalf("expected nil result, got %+v", result)
}
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
if resp.StatusCode != fiber.StatusConflict {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
}
}
func TestCreateOneAllowsWhenOnlySoftDeletedEmptyKandangExists(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
deletedAt := mustDateTime(t, "2026-01-11 10:00:00")
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr("DRAFT"), &deletedAt)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-10",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
})
if serviceErr != nil {
t.Fatalf("expected no error, got %v", serviceErr)
}
if resp.StatusCode != fiber.StatusCreated {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Category != "cleaning" {
t.Fatalf("expected category cleaning, got %s", result.Category)
}
var activeCount int64
if err := db.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-10"), "cleaning").
Count(&activeCount).Error; err != nil {
t.Fatalf("failed counting active checklists: %v", err)
}
if activeCount != 1 {
t.Fatalf("expected 1 active cleaning checklist, got %d", activeCount)
}
}
func TestCreateOneRejectsBulkEmptyKandangWhenDateRangeHasConflict(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), dailyChecklistCategoryEmptyKandang, strPtr("APPROVED"), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-01",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
EmptyKandang: true,
EmptyKandangEndDate: "2026-01-05",
})
if result != nil {
t.Fatalf("expected nil result, got %+v", result)
}
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
if resp.StatusCode != fiber.StatusConflict {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
}
var activeInRange int64
if err := db.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
Count(&activeInRange).Error; err != nil {
t.Fatalf("failed counting checklists in range: %v", err)
}
if activeInRange != 1 {
t.Fatalf("expected only pre-existing row to remain in range, got %d rows", activeInRange)
}
}
func TestCreateOneRejectsBulkEmptyKandangWhenRangeHasNonEmptyChecklist(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr("APPROVED"), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-01",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
EmptyKandang: true,
EmptyKandangEndDate: "2026-01-05",
})
if result != nil {
t.Fatalf("expected nil result, got %+v", result)
}
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
if resp.StatusCode != fiber.StatusConflict {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
}
}
func TestCreateOneRejectsBulkEmptyKandangWhenRangeHasRejectedChecklist(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr(dailyChecklistStatusRejected), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-01",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
EmptyKandang: true,
EmptyKandangEndDate: "2026-01-05",
})
if result != nil {
t.Fatalf("expected nil result, got %+v", result)
}
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
if resp.StatusCode != fiber.StatusConflict {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
}
}
func TestCreateOneAllowsBulkEmptyKandangWhenRangeHasOnlySoftDeletedChecklist(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
deletedAt := mustDateTime(t, "2026-01-11 10:00:00")
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr("APPROVED"), &deletedAt)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-01",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
EmptyKandang: true,
EmptyKandangEndDate: "2026-01-05",
})
if serviceErr != nil {
t.Fatalf("expected no error, got %v", serviceErr)
}
if resp.StatusCode != fiber.StatusCreated {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Category != dailyChecklistCategoryEmptyKandang {
t.Fatalf("expected category %s, got %s", dailyChecklistCategoryEmptyKandang, result.Category)
}
var activeInRange int64
if err := db.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
Count(&activeInRange).Error; err != nil {
t.Fatalf("failed counting checklists in range: %v", err)
}
if activeInRange != 1 {
t.Fatalf("expected 1 active empty_kandang checklist created for range, got %d", activeInRange)
}
var emptyRangeCount int64
if err := db.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date = ? AND end_date = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
Count(&emptyRangeCount).Error; err != nil {
t.Fatalf("failed counting empty kandang ranges: %v", err)
}
if emptyRangeCount != 1 {
t.Fatalf("expected 1 empty kandang range record for [2026-01-01, 2026-01-05], got %d", emptyRangeCount)
}
}
func TestCreateOneReusesExistingChecklistWhenNoEmptyKandangConflict(t *testing.T) {
svc, db := setupDailyChecklistServiceTest(t)
existingID := insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), "cleaning", strPtr("APPROVED"), nil)
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
Date: "2026-01-10",
KandangId: 1,
Category: "cleaning",
Status: "DRAFT",
})
if serviceErr != nil {
t.Fatalf("expected no error, got %v", serviceErr)
}
if resp.StatusCode != fiber.StatusCreated {
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Id != existingID {
t.Fatalf("expected existing checklist id %d to be reused, got %d", existingID, result.Id)
}
var activeCount int64
if err := db.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-10"), "cleaning").
Count(&activeCount).Error; err != nil {
t.Fatalf("failed counting active checklists: %v", err)
}
if activeCount != 1 {
t.Fatalf("expected 1 active cleaning checklist, got %d", activeCount)
}
}
func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *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 areas (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_by INTEGER NOT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE locations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
address TEXT NOT NULL,
area_id INTEGER NOT NULL,
created_by INTEGER NOT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE kandang_groups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL,
location_id INTEGER NOT NULL,
pic_id INTEGER NOT NULL,
created_by INTEGER NOT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE daily_checklists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kandang_id INTEGER NOT NULL,
checklist_id INTEGER NULL,
date DATE NOT NULL,
name TEXT NULL,
status TEXT NULL,
category TEXT NOT NULL,
total_score INTEGER NULL,
document_path TEXT NULL,
reject_reason TEXT NULL,
created_by INTEGER NULL,
deleted_by INTEGER NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE daily_checklist_empty_kandangs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
daily_checklist_id INTEGER NOT NULL,
kandang_id INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
created_by INTEGER NULL,
deleted_by INTEGER NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`INSERT INTO areas (id, name, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Area A', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO locations (id, name, address, area_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Farm A', 'Address', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO kandang_groups (id, name, status, location_id, pic_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Kandang A', 'ACTIVE', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
repo := repository.NewDailyChecklistRepository(db)
emptyRepo := repository.NewDailyChecklistEmptyKandangRepository(db)
svc := NewDailyChecklistService(repo, emptyRepo, nil, validator.New(), nil)
return svc, db
}
func runCreateOneRequest(t *testing.T, svc DailyChecklistService, req *validation.Create) (*entity.DailyChecklist, error, *http.Response) {
t.Helper()
app := fiber.New()
var (
result *entity.DailyChecklist
serviceErr error
)
app.Post("/", func(c *fiber.Ctx) error {
result, serviceErr = svc.CreateOne(c, req)
if serviceErr != nil {
return serviceErr
}
return c.SendStatus(fiber.StatusCreated)
})
resp, err := app.Test(httptest.NewRequest(http.MethodPost, "/", nil))
if err != nil {
t.Fatalf("failed running fiber request: %v", err)
}
return result, serviceErr, resp
}
func insertDailyChecklistRow(t *testing.T, db *gorm.DB, kandangID uint, date time.Time, category string, status *string, deletedAt *time.Time) uint {
t.Helper()
row := &entity.DailyChecklist{
KandangId: kandangID,
Date: date,
Category: category,
Status: status,
}
if deletedAt != nil {
row.DeletedAt = gorm.DeletedAt{
Time: *deletedAt,
Valid: true,
}
}
if err := db.Create(row).Error; err != nil {
t.Fatalf("failed inserting daily checklist row: %v", err)
}
return row.Id
}
func assertFiberErrorCode(t *testing.T, err error, expectedCode int) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var fiberErr *fiber.Error
if !errors.As(err, &fiberErr) {
t.Fatalf("expected *fiber.Error, got %T (%v)", err, err)
}
if fiberErr.Code != expectedCode {
t.Fatalf("expected fiber error code %d, got %d", expectedCode, fiberErr.Code)
}
}
func mustDate(t *testing.T, raw string) time.Time {
t.Helper()
value, err := time.Parse(dailyChecklistDateLayout, raw)
if err != nil {
t.Fatalf("failed parsing date %q: %v", raw, err)
}
return value
}
func mustDateTime(t *testing.T, raw string) time.Time {
t.Helper()
value, err := time.Parse("2006-01-02 15:04:05", raw)
if err != nil {
t.Fatalf("failed parsing datetime %q: %v", raw, err)
}
return value
}
func strPtr(value string) *string {
v := value
return &v
}
@@ -5,10 +5,12 @@ import (
)
type Create struct {
Date string `json:"date" validate:"required"`
KandangId uint `json:"kandang_id" validate:"required"`
Category string `json:"category" validate:"required"`
Status string `json:"status" validate:"required"`
Date string `json:"date" validate:"required"`
KandangId uint `json:"kandang_id" validate:"required"`
Category string `json:"category" validate:"required"`
Status string `json:"status" validate:"required"`
EmptyKandang bool `json:"empty_kandang"`
EmptyKandangEndDate string `json:"empty_kandang_end_date" validate:"omitempty"`
}
type Update struct {
@@ -18,6 +20,12 @@ type Update struct {
DeletedDocumentIDs *string `form:"deleted_document_ids" json:"deleted_document_ids"`
}
type BulkStatusUpdate struct {
IDs string `form:"ids" json:"ids" validate:"required_strict"`
Status string `form:"status" json:"status" validate:"required,oneof=APPROVED REJECTED"`
RejectReason *string `form:"reject_reason" json:"reject_reason"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
@@ -119,9 +119,9 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context,
var rows []RecordingWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE (r.day / 7 + 1)
END`
db := r.DB().WithContext(ctx).
@@ -503,9 +503,9 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context
var rows []ComparisonWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE (r.day / 7 + 1)
END`
db := r.DB().WithContext(ctx).
@@ -574,9 +574,9 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
var rows []EggQualityWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE (r.day / 7 + 1)
END`
db := r.DB().WithContext(ctx).
@@ -616,9 +616,9 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s
var rows []WeeklyEggWeightMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE (r.day / 7 + 1)
END`
db := r.DB().WithContext(ctx).
@@ -647,9 +647,9 @@ func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, s
var rows []WeeklyFeedUsageMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE (r.day / 7 + 1)
END`
db := r.DB().WithContext(ctx).
@@ -6,11 +6,16 @@ import (
"math"
"strconv"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/gofiber/fiber/v2"
)
@@ -19,6 +24,8 @@ type ExpenseController struct {
ExpenseService service.ExpenseService
}
const expenseExcelExportFetchLimit = 100
func NewExpenseController(expenseService service.ExpenseService) *ExpenseController {
return &ExpenseController{
ExpenseService: expenseService,
@@ -26,10 +33,48 @@ func NewExpenseController(expenseService service.ExpenseService) *ExpenseControl
}
func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
if exportprogress.IsProgressExportRequest(c) {
query, err := exportprogress.ParseQuery(c)
if err != nil {
return err
}
rows, err := u.ExpenseService.GetProgressRows(c, query)
if err != nil {
return err
}
content, err := exportprogress.BuildWorkbook("Expenses", query, rows)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate progress excel file")
}
filename := fmt.Sprintf("expenses_progress_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search", "")),
TransactionDate: strings.TrimSpace(c.Query("transaction_date", "")),
RealizationDate: strings.TrimSpace(c.Query("realization_date", "")),
LocationID: uint64(c.QueryInt("location_id", 0)),
VendorID: uint64(c.QueryInt("vendor_id", 0)),
Category: strings.TrimSpace(c.Query("category", "")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status", "")),
RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")),
ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)),
SortBy: strings.TrimSpace(c.Query("sort_by", "")),
SortOrder: strings.TrimSpace(c.Query("sort_order", "")),
}
if isAllExpenseExcelExportRequest(c) {
allResults, err := u.getAllExpensesForExcel(c, query)
if err != nil {
return err
}
return exportExpenseListExcel(c, allResults)
}
if query.Page < 1 || query.Limit < 1 {
@@ -56,6 +101,33 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
})
}
func (u *ExpenseController) getAllExpensesForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]dto.ExpenseListDTO, error) {
query := *baseQuery
query.Page = 1
query.Limit = expenseExcelExportFetchLimit
results := make([]dto.ExpenseListDTO, 0)
for {
pageResults, total, err := u.ExpenseService.GetAll(c, &query)
if err != nil {
return nil, err
}
if len(pageResults) == 0 || total == 0 {
break
}
results = append(results, pageResults...)
if int64(len(results)) >= total {
break
}
query.Page++
}
return results, nil
}
func (u *ExpenseController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
@@ -264,6 +336,51 @@ func (u *ExpenseController) Approval(c *fiber.Ctx) error {
})
}
func (u *ExpenseController) BulkApproveToStatus(c *fiber.Ctx) error {
req := new(validation.BulkApprovalRequest)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
targetStep, err := req.ResolveTarget()
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if req.RequiresDate(targetStep) && strings.TrimSpace(req.Date) == "" {
return fiber.NewError(fiber.StatusBadRequest, "date is required for REALISASI bulk approval")
}
if err := ensureExpenseBulkApprovalPermission(c, targetStep); err != nil {
return err
}
results, err := u.ExpenseService.BulkApproveToStatus(c, req, targetStep)
if err != nil {
return err
}
var (
data interface{}
message = "Bulk approve expense successfully"
)
if len(results) == 1 {
data = results[0]
} else {
message = "Bulk approve expenses successfully"
data = results
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: message,
Data: data,
})
}
func (u *ExpenseController) CreateRealization(c *fiber.Ctx) error {
expenseID := c.Params("id")
id, err := strconv.Atoi(expenseID)
@@ -366,6 +483,52 @@ func (u *ExpenseController) CompleteExpense(c *fiber.Ctx) error {
})
}
func (u *ExpenseController) Pay(c *fiber.Ctx) error {
expenseID := c.Params("id")
id, err := strconv.Atoi(expenseID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID")
}
expense, err := u.ExpenseService.Pay(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Pay expense successfully",
Data: expense,
})
}
func ensureExpenseBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error {
requiredPerms := []string{}
switch targetStep {
case utils.ExpenseStepHeadArea:
requiredPerms = []string{m.P_ExpenseApprovalHeadArea}
case utils.ExpenseStepUnitVicePresident:
requiredPerms = []string{m.P_ExpenseApprovalUnitVicePresident}
case utils.ExpenseStepFinance:
requiredPerms = []string{m.P_ExpenseApprovalFinance}
case utils.ExpenseStepRealisasi:
requiredPerms = []string{m.P_ExpenseApprovalFinance, m.P_ExpenseCreateRealizations}
default:
return fiber.NewError(fiber.StatusBadRequest, "Invalid approval target")
}
for _, perm := range requiredPerms {
if !m.HasPermission(c, perm) {
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission")
}
}
return nil
}
func (u *ExpenseController) DeleteDocument(c *fiber.Ctx) error {
expenseID, err := strconv.Atoi(c.Params("id"))
if err != nil {
@@ -0,0 +1,225 @@
package controller
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
type expenseServiceStub struct {
getAllCalls []validation.Query
}
var _ service.ExpenseService = (*expenseServiceStub)(nil)
func (s *expenseServiceStub) GetAll(_ *fiber.Ctx, params *validation.Query) ([]dto.ExpenseListDTO, int64, error) {
callCopy := *params
s.getAllCalls = append(s.getAllCalls, callCopy)
switch params.Page {
case 1:
return []dto.ExpenseListDTO{
buildExpenseListForControllerTest("EXP-00001"),
buildExpenseListForControllerTest("EXP-00002"),
}, 3, nil
case 2:
return []dto.ExpenseListDTO{
buildExpenseListForControllerTest("EXP-00003"),
}, 3, nil
default:
return []dto.ExpenseListDTO{}, 3, nil
}
}
func (s *expenseServiceStub) GetOne(_ *fiber.Ctx, _ uint) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) CreateOne(_ *fiber.Ctx, _ *validation.Create) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) UpdateOne(_ *fiber.Ctx, _ *validation.Update, _ uint) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) DeleteOne(_ *fiber.Ctx, _ uint64) error {
return nil
}
func (s *expenseServiceStub) CreateRealization(_ *fiber.Ctx, _ uint, _ *validation.CreateRealization) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) CompleteExpense(_ *fiber.Ctx, _ uint, _ *string) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) UpdateRealization(_ *fiber.Ctx, _ uint, _ *validation.UpdateRealization) (*dto.ExpenseDetailDTO, error) {
return &dto.ExpenseDetailDTO{}, nil
}
func (s *expenseServiceStub) DeleteDocument(_ *fiber.Ctx, _ uint, _ uint64, _ bool) error {
return nil
}
func (s *expenseServiceStub) Approval(_ *fiber.Ctx, _ *validation.ApprovalRequest, _ string) ([]dto.ExpenseDetailDTO, error) {
return nil, nil
}
func (s *expenseServiceStub) BulkApproveToStatus(_ *fiber.Ctx, _ *validation.BulkApprovalRequest, _ approvalutils.ApprovalStep) ([]dto.ExpenseDetailDTO, error) {
return nil, nil
}
func (s *expenseServiceStub) GetProgressRows(_ *fiber.Ctx, _ *exportprogress.Query) ([]exportprogress.Row, error) {
return nil, nil
}
func TestExpenseControllerGetAllExportAllIgnoresRequestLimit(t *testing.T) {
app := fiber.New()
stub := &expenseServiceStub{}
ctrl := NewExpenseController(stub)
app.Get("/expenses", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/expenses?export=excel&type=all&page=9&limit=1&search=operasional",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") {
t.Fatalf("unexpected content-type: %s", contentType)
}
disposition := resp.Header.Get("Content-Disposition")
if !strings.Contains(disposition, "expenses_all_") {
t.Fatalf("unexpected content-disposition: %s", disposition)
}
if len(stub.getAllCalls) != 2 {
t.Fatalf("expected 2 GetAll calls, got %d", len(stub.getAllCalls))
}
firstCall := stub.getAllCalls[0]
secondCall := stub.getAllCalls[1]
if firstCall.Page != 1 || secondCall.Page != 2 {
t.Fatalf("expected internal paging page 1 and 2, got %d and %d", firstCall.Page, secondCall.Page)
}
if firstCall.Limit != expenseExcelExportFetchLimit || secondCall.Limit != expenseExcelExportFetchLimit {
t.Fatalf("expected internal limit %d, got %d and %d", expenseExcelExportFetchLimit, firstCall.Limit, secondCall.Limit)
}
if firstCall.Search != "operasional" {
t.Fatalf("expected search filter to be forwarded, got %q", firstCall.Search)
}
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read excel payload: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(payload))
if err != nil {
t.Fatalf("failed to parse excel payload: %v", err)
}
defer file.Close()
if got, _ := file.GetCellValue(expenseExportSheetName, "A1"); got != "No" {
t.Fatalf("expected A1 header to be No, got %q", got)
}
if got, _ := file.GetCellValue(expenseExportSheetName, "C2"); got != "EXP-00001" {
t.Fatalf("expected first row reference EXP-00001, got %q", got)
}
}
func TestExpenseControllerGetAllKeepsPaginationValidationForNonExportAll(t *testing.T) {
app := fiber.New()
stub := &expenseServiceStub{}
ctrl := NewExpenseController(stub)
app.Get("/expenses", ctrl.GetAll)
req := httptest.NewRequest(http.MethodGet, "/expenses?page=1&limit=0", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusBadRequest {
t.Fatalf("expected status 400, got %d", resp.StatusCode)
}
}
func TestExpenseControllerGetAllProgressExportUnchanged(t *testing.T) {
app := fiber.New()
stub := &expenseServiceStub{}
ctrl := NewExpenseController(stub)
app.Get("/expenses", ctrl.GetAll)
req := httptest.NewRequest(
http.MethodGet,
"/expenses?export=excel&type=progress&start_date=2026-04-01&end_date=2026-04-22&limit=0",
nil,
)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected app.Test error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
if len(stub.getAllCalls) != 0 {
t.Fatalf("expected list GetAll not to be called for progress export, got %d calls", len(stub.getAllCalls))
}
}
func buildExpenseListForControllerTest(referenceNumber string) dto.ExpenseListDTO {
approvedAction := string(entity.ApprovalActionApproved)
return dto.ExpenseListDTO{
ExpenseBaseDTO: dto.ExpenseBaseDTO{
ReferenceNumber: referenceNumber,
PoNumber: "PO-" + strings.TrimPrefix(referenceNumber, "EXP-"),
TransactionDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Category: "BOP",
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
Location: &locationDTO.LocationRelationDTO{
Name: "Farm A",
},
},
GrandTotal: 1500000,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Finance",
Action: &approvedAction,
},
}
}
@@ -0,0 +1,295 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
const expenseExportSheetName = "Expenses"
func isAllExpenseExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") &&
strings.EqualFold(strings.TrimSpace(c.Query("type")), "all")
}
func exportExpenseListExcel(c *fiber.Ctx, items []dto.ExpenseListDTO) error {
content, err := buildExpenseExportWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("expenses_all_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func buildExpenseExportWorkbook(items []dto.ExpenseListDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != expenseExportSheetName {
if err := file.SetSheetName(defaultSheet, expenseExportSheetName); err != nil {
return nil, err
}
}
if err := setExpenseExportColumns(file, expenseExportSheetName); err != nil {
return nil, err
}
if err := setExpenseExportHeaders(file, expenseExportSheetName); err != nil {
return nil, err
}
if err := setExpenseExportRows(file, expenseExportSheetName, items); err != nil {
return nil, err
}
if err := file.SetPanes(expenseExportSheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func setExpenseExportColumns(file *excelize.File, sheet string) error {
columnWidths := map[string]float64{
"A": 8,
"B": 16,
"C": 20,
"D": 18,
"E": 18,
"F": 16,
"G": 24,
"H": 22,
"I": 16,
"J": 24,
}
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return file.SetRowHeight(sheet, 1, 24)
}
func setExpenseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"No",
"No. PO",
"No. Referensi",
"Tanggal Realisasi",
"Tanggal Transaksi",
"Kategori",
"Produk",
"Lokasi",
"Grand Total",
"Status",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+"1", header); err != nil {
return err
}
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
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 err
}
return file.SetCellStyle(sheet, "A1", "J1", headerStyle)
}
func setExpenseExportRows(file *excelize.File, sheet string, items []dto.ExpenseListDTO) error {
if len(items) == 0 {
return nil
}
for i, item := range items {
row := strconv.Itoa(i + 2)
if err := file.SetCellValue(sheet, "A"+row, i+1); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+row, safeExpenseExportText(item.PoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+row, safeExpenseExportText(item.ReferenceNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+row, formatExpenseExportDate(item.RealizationDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+row, formatExpenseExportDate(&item.TransactionDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+row, safeExpenseExportText(item.Category)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+row, safeExpenseSupplierName(item.Supplier)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+row, safeExpenseLocationName(item.Location)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+row, safeExpenseExportNumber(item.GrandTotal)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+row, formatExpenseExportStatus(item.LatestApproval)); err != nil {
return err
}
}
lastRow := len(items) + 1
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
WrapText: false,
},
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 err
}
if err := file.SetCellStyle(sheet, "A2", "J"+strconv.Itoa(lastRow), dataStyle); err != nil {
return err
}
ordinalStyle, 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 err
}
if err := file.SetCellStyle(sheet, "A2", "A"+strconv.Itoa(lastRow), ordinalStyle); err != nil {
return err
}
numberFormat := "#,##0.##"
numberStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "right",
Vertical: "center",
},
CustomNumFmt: &numberFormat,
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 err
}
return file.SetCellStyle(sheet, "I2", "I"+strconv.Itoa(lastRow), numberStyle)
}
func formatExpenseExportDate(value *time.Time) string {
if value == nil || value.IsZero() {
return "-"
}
t := *value
location, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(location)
}
return t.Format("02-01-2006")
}
func formatExpenseExportStatus(latestApproval *approvalDTO.ApprovalRelationDTO) string {
if latestApproval == nil {
return "-"
}
if latestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(*latestApproval.Action), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
return safeExpenseExportText(latestApproval.StepName)
}
func safeExpenseSupplierName(value *supplierDTO.SupplierRelationDTO) string {
if value == nil {
return "-"
}
return safeExpenseExportText(value.Name)
}
func safeExpenseLocationName(value *locationDTO.LocationRelationDTO) string {
if value == nil {
return "-"
}
return safeExpenseExportText(value.Name)
}
func safeExpenseExportNumber(value float64) float64 {
if math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return value
}
func safeExpenseExportText(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "-"
}
return trimmed
}
@@ -0,0 +1,137 @@
package controller
import (
"bytes"
"testing"
"time"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
"github.com/xuri/excelize/v2"
)
func TestBuildExpenseExportWorkbookHeadersAndRows(t *testing.T) {
realizationDate := time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC)
content, err := buildExpenseExportWorkbook([]dto.ExpenseListDTO{
{
ExpenseBaseDTO: dto.ExpenseBaseDTO{
PoNumber: "PO-00011",
ReferenceNumber: "EXP-00011",
RealizationDate: &realizationDate,
TransactionDate: time.Date(2026, time.April, 22, 0, 0, 0, 0, time.UTC),
Category: "BOP",
Supplier: &supplierDTO.SupplierRelationDTO{
Name: "Supplier A",
},
Location: &locationDTO.LocationRelationDTO{
Name: "Farm A",
},
},
GrandTotal: 1234567,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Finance",
},
},
{
ExpenseBaseDTO: dto.ExpenseBaseDTO{
PoNumber: "",
ReferenceNumber: "",
Category: "",
},
GrandTotal: 75000,
LatestApproval: &approvalDTO.ApprovalRelationDTO{
StepName: "Head Area",
Action: expenseStrPtr("REJECTED"),
},
},
{
ExpenseBaseDTO: dto.ExpenseBaseDTO{},
GrandTotal: 0,
LatestApproval: nil,
},
})
if err != nil {
t.Fatalf("buildExpenseExportWorkbook returned error: %v", err)
}
file, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
t.Fatalf("failed to open workbook bytes: %v", err)
}
defer file.Close()
sheets := file.GetSheetList()
if len(sheets) != 1 || sheets[0] != expenseExportSheetName {
t.Fatalf("expected single sheet %q, got %+v", expenseExportSheetName, sheets)
}
expectedHeaders := map[string]string{
"A1": "No",
"B1": "No. PO",
"C1": "No. Referensi",
"D1": "Tanggal Realisasi",
"E1": "Tanggal Transaksi",
"F1": "Kategori",
"G1": "Supplier",
"H1": "Lokasi",
"I1": "Grand Total",
"J1": "Status",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(expenseExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
assertExpenseCellEquals(t, file, "A2", "1")
assertExpenseCellEquals(t, file, "B2", "PO-00011")
assertExpenseCellEquals(t, file, "C2", "EXP-00011")
assertExpenseCellEquals(t, file, "D2", "22-04-2026")
assertExpenseCellEquals(t, file, "E2", "22-04-2026")
assertExpenseCellEquals(t, file, "F2", "BOP")
assertExpenseCellEquals(t, file, "G2", "Supplier A")
assertExpenseCellEquals(t, file, "H2", "Farm A")
assertExpenseCellEquals(t, file, "J2", "Finance")
rawGrandTotal, err := file.GetCellValue(expenseExportSheetName, "I2", excelize.Options{RawCellValue: true})
if err != nil {
t.Fatalf("GetCellValue(I2, RawCellValue) failed: %v", err)
}
if rawGrandTotal != "1234567" {
t.Fatalf("expected raw I2 grand total 1234567, got %q", rawGrandTotal)
}
assertExpenseCellEquals(t, file, "B3", "-")
assertExpenseCellEquals(t, file, "C3", "-")
assertExpenseCellEquals(t, file, "D3", "-")
assertExpenseCellEquals(t, file, "E3", "-")
assertExpenseCellEquals(t, file, "F3", "-")
assertExpenseCellEquals(t, file, "G3", "-")
assertExpenseCellEquals(t, file, "H3", "-")
assertExpenseCellEquals(t, file, "J3", "Ditolak")
assertExpenseCellEquals(t, file, "J4", "-")
}
func assertExpenseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
t.Helper()
got, err := file.GetCellValue(expenseExportSheetName, cell)
if err != nil {
t.Fatalf("GetCellValue(%s) failed: %v", cell, err)
}
if got != expected {
t.Fatalf("expected %s=%q, got %q", cell, expected, got)
}
}
func expenseStrPtr(value string) *string {
return &value
}
@@ -29,6 +29,7 @@ type ExpenseBaseDTO struct {
RealizationDate *time.Time `json:"realization_date,omitempty"`
TransactionDate time.Time `json:"transaction_date"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
IsPaid bool `json:"is_paid"`
}
type ExpenseListDTO struct {
@@ -86,6 +87,7 @@ type KandangGroupDTO struct {
type DocumentDTO struct {
ID uint64 `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
}
// === MAPPERS ===
@@ -126,6 +128,7 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO {
RealizationDate: realizationDate,
TransactionDate: e.TransactionDate,
Location: location,
IsPaid: e.IsPaid,
}
}
@@ -184,6 +187,7 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
documents = append(documents, DocumentDTO{
ID: uint64(doc.Id),
Path: doc.Path,
Name: doc.Name,
})
}
@@ -191,6 +195,7 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
realizationDocs = append(realizationDocs, DocumentDTO{
ID: uint64(doc.Id),
Path: doc.Path,
Name: doc.Name,
})
}
@@ -6,6 +6,7 @@ import (
"fmt"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -20,6 +21,7 @@ type ExpenseRepository interface {
WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB
CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error)
DeleteOne(ctx context.Context, id uint) error
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
}
type ExpenseRepositoryImpl struct {
@@ -130,3 +132,64 @@ func (r *ExpenseRepositoryImpl) DeleteOne(ctx context.Context, id uint) error {
}
return nil
}
func (r *ExpenseRepositoryImpl) GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error) {
const unassignedSQL = "'" + exportprogress.UnassignedKandangName + "'"
query := r.DB().WithContext(ctx).
Table("expenses AS e").
Select(`
'Expenses' AS module,
COALESCE(pf.flock_name, loc.name, fallback_loc.name, 'Unknown Farm') AS farm_name,
COALESCE(k.name, `+unassignedSQL+`, 'Unknown Kandang') AS kandang_name,
CAST(DATE(e.transaction_date) AS TEXT) AS activity_date,
COUNT(*) AS count
`).
Joins("LEFT JOIN (SELECT DISTINCT expense_id, project_flock_kandang_id, kandang_id FROM expense_nonstocks) en ON en.expense_id = e.id").
Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Joins("LEFT JOIN kandangs k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)").
Joins("LEFT JOIN locations loc ON loc.id = k.location_id").
Joins("LEFT JOIN locations fallback_loc ON fallback_loc.id = e.location_id").
Where("e.deleted_at IS NULL").
Where("DATE(e.transaction_date) >= DATE(?)", startDate).
Where("DATE(e.transaction_date) <= DATE(?)", endDate)
if restrict {
if len(allowedLocationIDs) == 0 {
return []exportprogress.Row{}, nil
}
query = query.Where("e.location_id IN ?", allowedLocationIDs)
}
type progressRowResult struct {
Module string
FarmName string
KandangName string
ActivityDate string
Count int
}
scanned := make([]progressRowResult, 0)
err := query.
Group("DATE(e.transaction_date), COALESCE(pf.flock_name, loc.name, fallback_loc.name, 'Unknown Farm'), COALESCE(k.name, " + unassignedSQL + ", 'Unknown Kandang')").
Order("activity_date ASC, farm_name ASC, kandang_name ASC").
Scan(&scanned).Error
if err != nil {
return nil, err
}
rows := make([]exportprogress.Row, 0, len(scanned))
for _, item := range scanned {
activityDate, err := exportprogress.ParseActivityDate(item.ActivityDate)
if err != nil {
return nil, err
}
rows = append(rows, exportprogress.Row{
Module: item.Module,
FarmName: item.FarmName,
KandangName: item.KandangName,
ActivityDate: activityDate,
Count: item.Count,
})
}
return rows, nil
}
@@ -0,0 +1,72 @@
package repository
import (
"context"
"testing"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestExpenseRepositoryGetProgressRows(t *testing.T) {
db := openExpenseProgressTestDB(t)
repo := NewExpenseRepository(db)
mustExec(t, db, `CREATE TABLE locations (id INTEGER PRIMARY KEY, name TEXT)`)
mustExec(t, db, `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, flock_name TEXT)`)
mustExec(t, db, `CREATE TABLE kandangs (id INTEGER PRIMARY KEY, name TEXT, location_id INTEGER)`)
mustExec(t, db, `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER, kandang_id INTEGER)`)
mustExec(t, db, `CREATE TABLE expenses (id INTEGER PRIMARY KEY, location_id INTEGER, transaction_date DATE, deleted_at DATETIME)`)
mustExec(t, db, `CREATE TABLE expense_nonstocks (id INTEGER PRIMARY KEY, expense_id INTEGER, project_flock_kandang_id INTEGER, kandang_id INTEGER)`)
mustExec(t, db, `INSERT INTO locations (id, name) VALUES (1, 'Farm Location')`)
mustExec(t, db, `INSERT INTO project_flocks (id, flock_name) VALUES (1, 'Farm A')`)
mustExec(t, db, `INSERT INTO kandangs (id, name, location_id) VALUES (1, 'Kandang 1', 1), (2, 'Kandang 2', 1)`)
mustExec(t, db, `INSERT INTO project_flock_kandangs (id, project_flock_id, kandang_id) VALUES (1, 1, 1), (2, 1, 2)`)
mustExec(t, db, `INSERT INTO expenses (id, location_id, transaction_date, deleted_at) VALUES (1, 1, '2026-06-10', NULL), (2, 1, '2026-06-10', NULL)`)
mustExec(t, db, `INSERT INTO expense_nonstocks (id, expense_id, project_flock_kandang_id, kandang_id) VALUES
(1, 1, 1, NULL),
(2, 1, 1, NULL),
(3, 1, 2, NULL)`)
rows, err := repo.GetProgressRows(context.Background(), time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), nil, false)
if err != nil {
t.Fatalf("GetProgressRows failed: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3 grouped rows, got %d", len(rows))
}
assertProgressRow(t, rows, "Farm A", "Kandang 1", "2026-06-10", 1)
assertProgressRow(t, rows, "Farm A", "Kandang 2", "2026-06-10", 1)
assertProgressRow(t, rows, "Farm Location", "Farm-level / Unassigned", "2026-06-10", 1)
}
func openExpenseProgressTestDB(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)
}
return db
}
func mustExec(t *testing.T, db *gorm.DB, query string, args ...any) {
t.Helper()
if err := db.Exec(query, args...).Error; err != nil {
t.Fatalf("exec failed for %q: %v", query, err)
}
}
func assertProgressRow(t *testing.T, rows []exportprogress.Row, farm, kandang, date string, count int) {
t.Helper()
for _, row := range rows {
if row.FarmName == farm && row.KandangName == kandang && row.ActivityDate.Format("2006-01-02") == date && row.Count == count {
return
}
}
t.Fatalf("expected row farm=%s kandang=%s date=%s count=%d, got %+v", farm, kandang, date, count, rows)
}
@@ -177,10 +177,48 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
return nil, 0, err
}
sortExpr := "expense_realizations.created_at"
order := "DESC"
if filters.SortOrder == "asc" {
order = "ASC"
}
switch filters.SortBy {
case "po_number":
sortExpr = "expenses.po_number"
case "reference_number":
sortExpr = "expenses.reference_number"
case "realization_date":
sortExpr = "expenses.realization_date"
case "transaction_date":
sortExpr = "expenses.transaction_date"
case "category":
sortExpr = "expenses.category"
case "product":
sortExpr = "(SELECT name FROM nonstocks WHERE id = expense_nonstocks.nonstock_id)"
case "supplier":
sortExpr = "suppliers.name"
case "location":
sortExpr = "(SELECT l.name FROM kandangs k JOIN locations l ON l.id = k.location_id WHERE k.id = expense_nonstocks.kandang_id)"
case "kandang":
sortExpr = "(SELECT name FROM kandangs WHERE id = expense_nonstocks.kandang_id)"
case "qty_pengajuan":
sortExpr = "expense_nonstocks.qty"
case "price_pengajuan":
sortExpr = "expense_nonstocks.price"
case "total_pengajuan":
sortExpr = "expense_nonstocks.qty * expense_nonstocks.price"
case "qty_realisasi":
sortExpr = "expense_realizations.qty"
case "price_realisasi":
sortExpr = "expense_realizations.price"
case "total_realisasi":
sortExpr = "expense_realizations.qty * expense_realizations.price"
}
if err := db.
Offset(offset).
Limit(limit).
Order("expense_realizations.created_at DESC").
Order(sortExpr + " " + order).
Find(&realizations).Error; err != nil {
return nil, 0, err
}
+2
View File
@@ -31,10 +31,12 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService
route.Post("/approvals/head-area", m.RequirePermissions(m.P_ExpenseApprovalHeadArea), ctrl.Approval)
route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval)
route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval)
route.Post("/approvals/bulk", ctrl.BulkApproveToStatus)
route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization)
route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization)
route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense)
route.Patch("/:id/pay", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.Pay)
route.Delete("/:id/documents/:documentId", m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument)
route.Delete("/:id/realization-documents/:documentId", m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument)
}
@@ -5,8 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -34,9 +36,12 @@ type ExpenseService interface {
DeleteOne(ctx *fiber.Ctx, id uint64) error
CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error)
CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error)
Pay(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error)
UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error)
DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error
Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error)
BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error)
GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
}
type expenseService struct {
@@ -83,6 +88,25 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
})
}
func normalizeExpenseApprovalStatusFilter(raw string) string {
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(raw), " ", "_")) {
case "HEAD_AREA", "APPROVAL_HEAD_AREA":
return "Approval Head Area"
case "UNIT_VICE_PRESIDENT", "APPROVAL_UNIT_VICE_PRESIDENT", "BUSINESS_UNIT_VICE_PRESIDENT", "APPROVAL_BUSINESS_UNIT_VICE_PRESIDENT":
return "Approval Unit Vice President"
case "FINANCE", "APPROVAL_FINANCE":
return "Approval Finance"
case "REALISASI":
return "Realisasi"
case "SELESAI":
return "Selesai"
case "DITOLAK", "REJECTED":
return "REJECTED"
default:
return ""
}
}
func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -95,10 +119,210 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
if params.Search != "" {
return db.Where("category ILIKE ?", "%"+params.Search+"%")
db = db.Where("expenses.deleted_at IS NULL")
if params.TransactionDate != "" {
db = db.Where("DATE(expenses.transaction_date) = DATE(?)", params.TransactionDate)
}
if params.RealizationDate != "" {
db = db.Where("DATE(expenses.realization_date) = DATE(?)", params.RealizationDate)
}
if params.LocationID > 0 {
db = db.Where("expenses.location_id = ?", params.LocationID)
}
if params.VendorID > 0 {
db = db.Where("expenses.supplier_id = ?", params.VendorID)
}
if params.Category != "" {
db = db.Where("expenses.category = ?", params.Category)
}
if params.ProjectFlockID > 0 {
projectFlockJSON := fmt.Sprintf("[%d]", params.ProjectFlockID)
db = db.Where(`(
EXISTS (
SELECT 1
FROM expense_nonstocks en
LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id
WHERE en.expense_id = expenses.id
AND (
pfk.project_flock_id = ? OR
en.kandang_id IN (
SELECT kandang_id
FROM project_flock_kandangs
WHERE project_flock_id = ?
)
)
) OR
(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
)`, params.ProjectFlockID, params.ProjectFlockID, projectFlockJSON)
}
if params.ProjectFlockKandangID > 0 {
db = db.Where(`EXISTS (
SELECT 1
FROM expense_nonstocks en
LEFT JOIN project_flock_kandangs selected_pfk ON selected_pfk.id = ?
WHERE en.expense_id = expenses.id
AND (
en.project_flock_kandang_id = ? OR
(selected_pfk.kandang_id IS NOT NULL AND en.kandang_id = selected_pfk.kandang_id)
)
)`, params.ProjectFlockKandangID, params.ProjectFlockKandangID)
}
latestApprovalSubQuery := s.Repository.DB().
WithContext(c.Context()).
Table("approvals").
Select("DISTINCT ON (approvable_id) approvable_id, step_name, action, step_number").
Where("approvable_type = ?", utils.ApprovalWorkflowExpense.String()).
Order("approvable_id, action_at DESC, id DESC")
if approvalStatus := normalizeExpenseApprovalStatusFilter(params.ApprovalStatus); approvalStatus != "" {
if approvalStatus == "REJECTED" {
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
} else {
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND LOWER(latest_approval.step_name) = LOWER(?)
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
)`, latestApprovalSubQuery, approvalStatus, string(entity.ApprovalActionRejected))
}
}
switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(params.RealizationStatus), " ", "_")) {
case "REALIZED", "SUDAH_REALISASI":
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
AND latest_approval.step_number >= 5
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
case "NOT_REALIZED", "BELUM_REALISASI":
db = db.Where(`NOT EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
AND latest_approval.step_number >= 5
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
db = db.Where(`NOT EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
case "REJECTED", "DITOLAK":
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = expenses.id
AND latest_approval.action = ?
)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
}
if search := strings.ToLower(strings.TrimSpace(params.Search)); search != "" {
like := "%" + search + "%"
db = db.Where(`(
LOWER(COALESCE(expenses.reference_number, '')) LIKE ?
OR LOWER(COALESCE(expenses.po_number, '')) LIKE ?
OR LOWER(COALESCE(expenses.category, '')) LIKE ?
OR EXISTS (
SELECT 1
FROM suppliers s
WHERE s.id = expenses.supplier_id
AND LOWER(COALESCE(s.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM locations l
WHERE l.id = expenses.location_id
AND LOWER(COALESCE(l.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM users u
WHERE u.id = expenses.created_by
AND LOWER(COALESCE(u.name, '')) LIKE ?
)
OR EXISTS (
SELECT 1
FROM expense_nonstocks en
LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id
LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id
LEFT JOIN kandangs k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)
WHERE en.expense_id = expenses.id
AND (
LOWER(COALESCE(pf.flock_name, '')) LIKE ? OR
LOWER(COALESCE(k.name, '')) LIKE ?
)
)
OR EXISTS (
SELECT 1
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = expenses.id
AND (
LOWER(COALESCE(a.step_name, '')) LIKE ? OR
LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR
LOWER(COALESCE(a.notes, '')) LIKE ?
)
)
)`,
like,
like,
like,
like,
like,
like,
like,
like,
utils.ApprovalWorkflowExpense.String(),
like,
like,
like,
)
}
sortBy := strings.TrimSpace(params.SortBy)
sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder))
if sortOrder == "" {
sortOrder = "DESC"
}
switch sortBy {
case "reference_number":
return db.Order("expenses.reference_number " + sortOrder)
case "transaction_date":
return db.Order("expenses.transaction_date " + sortOrder)
case "realization_date":
return db.Order("expenses.realization_date " + sortOrder)
case "location":
return db.Order("(SELECT COALESCE(name,'') FROM locations WHERE id = expenses.location_id) " + sortOrder)
case "created_user":
return db.Order("(SELECT COALESCE(name,'') FROM users WHERE id = expenses.created_by) " + sortOrder)
case "supplier":
return db.Order("(SELECT COALESCE(name,'') FROM suppliers WHERE id = expenses.supplier_id) " + sortOrder)
case "grand_total":
return db.Order(`(SELECT COALESCE(
(SELECT SUM(er.qty * er.price) FROM expense_realizations er
JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id
WHERE en.expense_id = expenses.id),
(SELECT SUM(en2.qty * en2.price) FROM expense_nonstocks en2
WHERE en2.expense_id = expenses.id),
0)) ` + sortOrder)
case "is_paid":
return db.Order("expenses.is_paid " + sortOrder)
case "created_at":
return db.Order("expenses.created_at " + sortOrder)
default:
return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
if scopeErr != nil {
@@ -152,9 +376,29 @@ func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetail
expense.LatestApproval = approval
responseDTO := expenseDto.ToExpenseDetailDTO(expense)
for i := range responseDTO.Documents {
if url, err := commonSvc.ResolveDocumentURL(c.Context(), s.DocumentSvc, responseDTO.Documents[i].Path, 15*time.Minute); err == nil && url != "" {
responseDTO.Documents[i].Path = url
}
}
for i := range responseDTO.RealizationDocs {
if url, err := commonSvc.ResolveDocumentURL(c.Context(), s.DocumentSvc, responseDTO.RealizationDocs[i].Path, 15*time.Minute); err == nil && url != "" {
responseDTO.RealizationDocs[i].Path = url
}
}
return &responseDTO, nil
}
func (s expenseService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
locationScope, err := middleware.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, err
}
return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, locationScope.IDs, locationScope.Restrict)
}
func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
@@ -742,8 +986,12 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
return nil, err
}
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
actorID, err := middleware.ActorIDFromContext(c)
if err != nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
@@ -780,12 +1028,6 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
}
}
if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{
"realization_date": realizationDate,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
}
if s.DocumentSvc != nil && len(req.Documents) > 0 {
documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
for idx, file := range req.Documents {
@@ -795,7 +1037,6 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
Index: &idx,
})
}
actorID := uint(1) // TODO: replace with authenticated user id
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeExpenseRealization),
DocumentableID: uint64(expenseID),
@@ -807,6 +1048,12 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
}
}
if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{
"realization_date": realizationDate,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
}
approvalAction := entity.ApprovalActionCreated
if _, err := approvalSvc.CreateApproval(
c.Context(),
@@ -814,9 +1061,9 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
expenseID,
utils.ExpenseStepRealisasi,
&approvalAction,
uint(1), // TODO: replace with authenticated user id
nil); err != nil {
actorID,
nil,
); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
}
@@ -834,6 +1081,205 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
return responseDTO, nil
}
func (s *expenseService) BulkApproveToStatus(c *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
approvableIDs := utils.UniqueUintSlice(req.ApprovableIds)
if len(approvableIDs) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id")
}
actorID, err := middleware.ActorIDFromContext(c)
if err != nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
var realizationDate time.Time
if req.RequiresDate(target) {
realizationDate, err = utils.ParseDateString(req.Date)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
}
}
invalidateFromDateByExpenseID := make(map[uint]time.Time, len(approvableIDs))
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
expenseRepoTx := repository.NewExpenseRepository(tx)
for _, id := range approvableIDs {
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: expenseRepoTx.IdExists},
); err != nil {
return err
}
expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Preload("Nonstocks")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
}
latestApproval, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
}
if latestApproval == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for Expense %d", id))
}
if latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionRejected {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is rejected and cannot be bulk approved", id))
}
currentStep := approvalutils.ApprovalStep(latestApproval.StepNumber)
if currentStep >= target {
currentStepName := utils.ExpenseApprovalSteps[currentStep]
targetStepName := utils.ExpenseApprovalSteps[target]
if currentStep == target {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is already at %s step", id, targetStepName))
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is already beyond %s step (current step: %s)", id, targetStepName, currentStepName))
}
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
for step := currentStep + 1; step <= target; step++ {
if step == utils.ExpenseStepRealisasi {
if err := s.createRealizationFromExpenseLines(c.Context(), tx, expense, realizationDate, actorID, req.Notes); err != nil {
return err
}
invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate, realizationDate)
break
}
approvalAction := entity.ApprovalActionApproved
if _, err := approvalSvcTx.CreateApproval(
c.Context(),
utils.ApprovalWorkflowExpense,
id,
step,
&approvalAction,
actorID,
req.Notes,
); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval")
}
if step == utils.ExpenseStepFinance && expense.PoNumber == "" {
poNumber, err := s.generatePoNumber(tx, id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate PO number")
}
if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{"po_number": poNumber}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update PO number")
}
expense.PoNumber = poNumber
}
}
invalidateFromDateByExpenseID[id] = invalidateFromDate
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to bulk approve expenses")
}
results := make([]expenseDto.ExpenseDetailDTO, 0, len(approvableIDs))
for _, id := range approvableIDs {
responseDTO, err := s.GetOne(c, id)
if err != nil {
return nil, err
}
results = append(results, *responseDTO)
}
for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID {
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
}
return results, nil
}
func (s *expenseService) createRealizationFromExpenseLines(
ctx context.Context,
tx *gorm.DB,
expense *entity.Expense,
realizationDate time.Time,
actorID uint,
notes *string,
) error {
if expense == nil {
return fiber.NewError(fiber.StatusBadRequest, "Expense not found")
}
if tx == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Transaction is required")
}
if err := s.ensureProjectFlockNotClosedForExpense(ctx, expense); err != nil {
return err
}
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
expenseRepoTx := repository.NewExpenseRepository(tx)
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
for _, expenseNonstock := range expense.Nonstocks {
expenseNonstockID := expenseNonstock.Id
_, err := realizationRepoTx.GetByExpenseNonstockID(ctx, expenseNonstockID)
if err == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Realization already exists for expense nonstock %d", expenseNonstockID))
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing realization")
}
realization := &entity.ExpenseRealization{
ExpenseNonstockId: &expenseNonstockID,
Qty: expenseNonstock.Qty,
Price: expenseNonstock.Price,
Notes: expenseNonstock.Notes,
}
if err := realizationRepoTx.CreateOne(ctx, realization, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization")
}
}
if err := expenseRepoTx.PatchOne(ctx, uint(expense.Id), map[string]interface{}{
"realization_date": realizationDate,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
}
approvalAction := entity.ApprovalActionCreated
if _, err := approvalSvc.CreateApproval(
ctx,
utils.ApprovalWorkflowExpense,
uint(expense.Id),
utils.ExpenseStepRealisasi,
&approvalAction,
actorID,
notes,
); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
}
expense.RealizationDate = realizationDate
return nil
}
func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) {
if err := commonSvc.EnsureRelations(c.Context(),
@@ -898,6 +1344,41 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (
return responseDTO, nil
}
func (s *expenseService) Pay(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) {
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
); err != nil {
return nil, err
}
expense, err := s.Repository.GetByID(c.Context(), id, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
}
if expense.IsPaid {
return nil, fiber.NewError(fiber.StatusBadRequest, "Expense is already paid")
}
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, func(db *gorm.DB) *gorm.DB {
return db.Where("action = ?", entity.ApprovalActionApproved)
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
}
if latestApproval == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Expense must be approved by Finance (step 4) before payment")
}
if latestApproval.StepNumber < uint16(utils.ExpenseStepFinance) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Expense must be approved by Finance (step 4) before payment")
}
if err := s.Repository.PatchOne(c.Context(), id, map[string]any{"is_paid": true}, nil); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update payment status")
}
return s.GetOne(c, id)
}
func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil {
@@ -1,7 +1,12 @@
package validation
import (
"errors"
"mime/multipart"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
)
type Create struct {
@@ -37,9 +42,20 @@ type Update struct {
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=100"`
TransactionDate string `query:"transaction_date" validate:"omitempty,datetime=2006-01-02"`
RealizationDate string `query:"realization_date" validate:"omitempty,datetime=2006-01-02"`
LocationID uint64 `query:"location_id" validate:"omitempty,gt=0"`
VendorID uint64 `query:"vendor_id" validate:"omitempty,gt=0"`
Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=100"`
RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"`
ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=reference_number transaction_date realization_date location created_user supplier grand_total is_paid created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"`
}
type CreateRealization struct {
@@ -66,3 +82,31 @@ type ApprovalRequest struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"`
Notes *string `json:"notes" form:"notes"`
}
type BulkApprovalRequest struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"`
Status string `json:"status" validate:"required,max=100"`
Date string `json:"date,omitempty" validate:"omitempty,datetime=2006-01-02"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
func (r *BulkApprovalRequest) ResolveTarget() (approvalutils.ApprovalStep, error) {
status := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(r.Status), " ", "_"))
switch status {
case "HEAD_AREA", "APPROVAL_HEAD_AREA":
return utils.ExpenseStepHeadArea, nil
case "UNIT_VICE_PRESIDENT", "APPROVAL_UNIT_VICE_PRESIDENT", "BUSINESS_UNIT_VICE_PRESIDENT", "APPROVAL_BUSINESS_UNIT_VICE_PRESIDENT":
return utils.ExpenseStepUnitVicePresident, nil
case "FINANCE", "APPROVAL_FINANCE":
return utils.ExpenseStepFinance, nil
case "REALISASI":
return utils.ExpenseStepRealisasi, nil
default:
return 0, errors.New("status must be one of HEAD_AREA, UNIT_VICE_PRESIDENT, FINANCE, or REALISASI")
}
}
func (r *BulkApprovalRequest) RequiresDate(target approvalutils.ApprovalStep) bool {
return target == utils.ExpenseStepRealisasi
}
@@ -97,6 +97,8 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error {
CustomerIDs: customerIDs,
SupplierIDs: supplierIDs,
SortDate: c.Query("sort_date", ""),
SortBy: c.Query("sort_by", ""),
SortOrder: c.Query("sort_order", ""),
StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""),
}
@@ -72,27 +72,36 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
needsPartyJoin := params.Search != "" || params.SortBy == "customer_name"
needsBankJoin := params.Search != "" || params.SortBy == "bank"
if needsPartyJoin {
db = db.Joins(
"LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL",
string(utils.PaymentPartyCustomer),
).Joins(
"LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL",
string(utils.PaymentPartySupplier),
).Joins(
"LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL",
)
}
if needsBankJoin {
db = db.Joins("LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL")
}
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
db = db.Where(
`LOWER(payment_code) LIKE ? OR
`(LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(payment_method, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR
LOWER(COALESCE(notes, '')) LIKE ? OR
LOWER(COALESCE(customers.name, '')) LIKE ? OR
LOWER(COALESCE(suppliers.name, '')) LIKE ? OR
LOWER(COALESCE(banks.name, '')) LIKE ?`,
like, like, like, like, like, like, like, like,
LOWER(COALESCE(banks.name, '')) LIKE ? OR
CAST(payments.nominal AS TEXT) LIKE ? OR
TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?)`,
like, like, like, like, like, like, like, like, like, like,
)
}
@@ -136,7 +145,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
db = db.Where("payment_date < ?", *endDate)
}
return applyTransactionSort(db, params.SortDate)
return applyTransactionSort(db, params.SortBy, params.SortOrder, params.SortDate)
})
if err != nil {
@@ -268,13 +277,39 @@ func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Tim
return startPtr, endPtr, nil
}
func applyTransactionSort(db *gorm.DB, sortDate string) *gorm.DB {
func applyTransactionSort(db *gorm.DB, sortBy, sortOrder, sortDate string) *gorm.DB {
order := "DESC"
if strings.ToUpper(strings.TrimSpace(sortOrder)) == "ASC" {
order = "ASC"
}
switch strings.ToLower(strings.TrimSpace(sortBy)) {
case "payment_code":
return db.Order("payments.payment_code " + order)
case "reference_number":
return db.Order("payments.reference_number " + order)
case "transaction_type":
return db.Order("payments.transaction_type " + order)
case "customer_name":
return db.Order("COALESCE(customers.name, suppliers.name) " + order)
case "payment_date":
return db.Order("payments.payment_date " + order)
case "created_at":
return db.Order("payments.created_at " + order)
case "payment_method":
return db.Order("payments.payment_method " + order)
case "bank":
return db.Order("banks.account_number " + order)
case "expense_amount":
return db.Order("CASE WHEN payments.direction = 'OUT' THEN payments.nominal ELSE 0 END " + order)
case "income_amount":
return db.Order("CASE WHEN payments.direction = 'IN' THEN payments.nominal ELSE 0 END " + order)
}
switch strings.ToLower(strings.TrimSpace(sortDate)) {
case "created_at":
return db.Order("created_at DESC").Order("payment_date DESC")
case "payment_date":
return db.Order("payment_date DESC").Order("created_at DESC")
return db.Order("payments.created_at DESC").Order("payments.payment_date DESC")
default:
return db.Order("payment_date DESC").Order("created_at DESC")
return db.Order("payments.payment_date DESC").Order("payments.created_at DESC")
}
}
@@ -17,6 +17,8 @@ type Query struct {
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"`
SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=payment_code reference_number transaction_type customer_name payment_date created_at payment_method bank expense_amount income_amount"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
}

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