Compare commits

..

1230 Commits

Author SHA1 Message Date
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
giovanni f98c73f569 add command normalize data seed standar and price adjustment stocks 2026-04-20 00:03:59 +07:00
Adnan Zahir 75d42354e9 Merge branch 'codex/depresiasi' into 'development'
Codex/depresiasi

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

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

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

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

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

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

See merge request mbugroup/lti-api!415
2026-04-14 14:31:56 +07:00
Adnan Zahir 82794d3d9b Merge branch 'development' into 'codex/dashboard-without-uniformity'
# Conflicts:
#   internal/modules/dashboards/repositories/dashboard_stats.repository.go
#   internal/modules/dashboards/services/dashboard.service.go
#   internal/modules/production/uniformities/services/uniformity.service.go
2026-04-14 14:31:18 +07:00
Adnan Zahir ca698ff2ae codex/fix: uniformity week calculation 2026-04-14 13:09:47 +07:00
Adnan Zahir fbe0634d46 Merge branch 'feat/customer' into 'development'
[FEAT][BE]: add query param get customer has marketing

See merge request mbugroup/lti-api!413
2026-04-13 16:34:29 +07:00
giovanni bca02800d6 add query param get customer has marketing 2026-04-13 14:47:00 +07:00
Adnan Zahir 45e430f01d Merge branch 'feat/kandang-periode' into 'development'
[FEAT][BE]: adjust api get all project flock kandang with periode

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

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

See merge request mbugroup/lti-api!408
2026-04-10 15:21:23 +07:00
giovanni 3d75251c96 adjust api get all project flock kandang with periode 2026-04-10 14:09:31 +07:00
giovanni 7c848bc50d adjust validation from week 19 2026-04-09 17:00:03 +07:00
Adnan Zahir ddcf13e2ff Merge branch 'feat/dashboard-uniformity' into 'development'
[FEAT][BE]: adjust dashboard uniformity and validation add uniformity

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

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

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

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

See merge request mbugroup/lti-api!402
2026-04-08 16:54:53 +07:00
giovanni a6995f8e18 fix edit receipt purchase 2026-04-08 16:18:55 +07:00
Adnan Zahir 7638c183f5 codex/fix: dashboard independent recording values without uniformity 2026-04-08 15:13:31 +07:00
giovanni 450d1e8cee add filter lokasi and bop to purchase order 2026-04-08 14:24:04 +07:00
ragilap b58e9a10b1 fix filter purchase supplier repport 2026-04-08 14:13:19 +07:00
ragilap aa9863646e fix filter purchase ?approval_status=approved,rejected and ?product_category_id=1,2,3 2026-04-08 14:12:18 +07:00
ragilap 2a3154042c fix filter purchase query param and search 2026-04-08 14:07:40 +07:00
giovanni 80f190b69b fix get detail marketing delivery 2026-04-08 13:41:17 +07:00
Adnan Zahir 079ae01b94 Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: adjust calculate total price create sales order for telur and convertion peti and qty

See merge request mbugroup/lti-api!400
2026-04-07 22:44:49 +07:00
Adnan Zahir ee7fa71139 codex/command: migrate egg stocks from kandang to farm (adjustment_stocks only) 2026-04-07 21:54:51 +07:00
Adnan Zahir f2827d5352 codex/command: migrate egg stocks from kandang to farm 2026-04-07 20:28:05 +07:00
giovanni 18cf180982 adjust calculate total price create sales order for telur and convertion peti and qty 2026-04-07 18:39:32 +07:00
giovanni fc0b45b433 remove migration change type data qty recording eggs 2026-04-07 15:48:59 +07:00
giovanni 4d85d6f320 add migration change data type field qty di table recording eggs 2026-04-07 15:35:14 +07:00
Adnan Zahir e3cfb2648b Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: Codex/sales at farm level

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

See merge request mbugroup/lti-api!345
2026-03-04 14:30:40 +07:00
ragilap d334f46829 [FEAT/BE] wiring recording,transfer_stock,transfer_laying,marketing for consumer chick in project flock population 2026-03-04 12:41:26 +07:00
Hafizh A. Y 80135466df fix: all implemented fifo v2 2026-03-03 16:15:35 +07:00
Hafizh A. Y 9d5f733172 fix: first push need support testing, and implemented fifo v2 to all modules 2026-03-03 16:10:12 +07:00
Adnan Zahir 4bb750fc98 dev: initiate adjustment recording and trf to laying 2026-03-03 15:47:29 +07:00
kris 55451a5ea8 Update .gitlab-ci.yml file 2026-03-03 07:16:29 +00:00
kris aafbba199d Update .gitlab-ci.yml file 2026-03-03 07:06:05 +00:00
kris 0206d9dc68 Edit development.yml 2026-03-03 05:34:21 +00:00
kris e9d1cca294 Edit development.yml 2026-03-03 05:12:05 +00:00
Hafizh A. Y. d335597bed Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'
[FIX/BE] Implementation Fifo V2 to all module

See merge request mbugroup/lti-api!343
2026-03-03 04:09:27 +00:00
Hafizh A. Y f6e25be76b fix: chickin include stock allocation, fix calculation hpp 2026-03-03 10:36:48 +07:00
Hafizh A. Y d5a1751868 fix: all implemented fifo v2 2026-03-02 12:44:20 +07:00
Hafizh A. Y dd61b66af0 fix: adjusment module depletion, chickin, recording refactor 2026-02-28 21:35:07 +07:00
Hafizh A. Y 944604adad fix: first push need support testing, and implemented fifo v2 to all modules 2026-02-27 19:09:01 +07:00
Hafizh A. Y. 3bc0685b46 Merge branch 'revert-915302c4' into 'dev/fifo-v2'
Revert "Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'"

See merge request mbugroup/lti-api!342
2026-02-27 09:37:50 +00:00
Hafizh A. Y. f6c88b773d Revert "Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'"
This reverts merge request !340
2026-02-27 09:37:03 +00:00
Hafizh A. Y. 915302c445 Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'
feat: refactor module adjusment stock, adjust constant, adjust table migration and create command reflow and delete module adjusment stock

See merge request mbugroup/lti-api!340
2026-02-27 08:30:53 +00:00
Hafizh A. Y e7e065c320 fix: first push implementation fifo v2 to all stockable and useable 2026-02-27 15:21:55 +07:00
Adnan Zahir 0bcda8ad82 Merge branch 'staging' into 'production'
Staging

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

See merge request mbugroup/lti-api!335
2026-02-26 16:17:04 +07:00
Hafizh A. Y. a2de21e351 Merge branch 'fix/refactor-adjustment' into 'dev/fifo-v2'
feat: refactor module adjustment stock, etc.

See merge request mbugroup/lti-api!338
2026-02-26 07:40:58 +00:00
Hafizh A. Y a8903b3598 feat: refactor module adjusment stock, adjust constant, adjust table migration and create command reflow and delete module adjusment stock 2026-02-26 14:37:54 +07:00
Adnan Zahir 4d009978ae Merge branch 'fix/be/closing-unclosing' into 'development'
[FEAT/BE] fixing overhead,sapronak,perhitungan sapronak

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

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

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

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

See merge request mbugroup/lti-api!331
2026-02-20 09:53:22 +07:00
Adnan Zahir 5e28721651 Merge branch 'fix/stock' into 'development'
[FIX][BE]: fix insert stock to stocklog

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

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

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

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

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

See merge request mbugroup/lti-api!325
2026-02-17 08:32:17 +00:00
ragilap 756fc431b3 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/chickin-purchase-relation 2026-02-12 14:12:05 +07:00
ragilap 71ce855feb [FEAT/BE] change current stock with pending in product warehouse 2026-02-12 14:11:52 +07:00
Adnan Zahir e323f42c11 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!323
2026-02-12 11:04:50 +07:00
Hafizh A. Y. 5705e39f53 Merge branch 'fix/BE/chickin-purchase-relation' into 'development'
[FEAT/BE] recording reject productwarehouse rolback

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

See merge request mbugroup/lti-api!322
2026-02-10 03:47:04 +00:00
ragilap 8ad923a90a [FEAT/BE] Add saparator type search get all productwarehouse 2026-02-09 16:48:42 +07:00
Adnan Zahir 0d5044b7bf Merge branch 'staging' into 'production'
Staging

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

See merge request mbugroup/lti-api!319
2026-02-07 17:00:36 +07:00
Adnan Zahir b43979bbba Merge branch 'feat/delivery-order-purchase-recordings' into 'development'
Feat/delivery order purchase recordings

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

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

See merge request mbugroup/lti-api!316
2026-02-06 16:51:05 +00:00
M1 AIR 1a56b37e4e Create job for MR 2026-02-06 23:36:20 +07:00
ragilap 4340828fec [FEAT/BE] Add telur seeder and saparator category 2026-02-06 19:26:00 +07:00
ragilap f74b6476de [FEAT/BE] Add filter delivery order, adjust response purchase and fcr growing recording 2026-02-06 14:13:05 +07:00
Adnan Zahir 0db1aaaab7 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!314
2026-02-06 11:31:55 +07:00
Hafizh A. Y. 3011735458 Merge branch 'fix/hpp-harian-feed' into 'development'
[FIX][BE]: add query for feed supplier

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

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

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

See merge request mbugroup/lti-api!309
2026-02-05 07:17:06 +00:00
Hafizh A. Y fc157dfd79 fix(BE): filter by transaction or realization in report customer payment 2026-02-05 14:16:02 +07:00
Adnan Zahir 2b258908ef Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!310
2026-02-05 10:32:25 +07:00
Hafizh A. Y. f407ef6a0c Merge branch 'fix/multiple-filter-finance' into 'development'
fix(BE): multiple filter, all search

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

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

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

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

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

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

See merge request mbugroup/lti-api!299
2026-02-03 13:05:23 +07:00
Hafizh A. Y. 22038533d7 Merge branch 'FEAT/BE/Production_standard' into 'development'
FEAT[BE] :add production standard detail creation for growing only FCR

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

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

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

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

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

See merge request mbugroup/lti-api!296
2026-02-03 02:33:54 +00:00
aguhh18 9a328ae1e4 FEAT[BE] :implement proportional distribution with rounding for stock allocation in transfer laying approval process 2026-02-03 08:06:52 +07:00
aguhh18 58ae03a090 FIX[BE] :remove unnecessary quantity calculations in Adjustment method to streamline stock adjustment logic 2026-02-02 21:45:18 +07:00
aguhh18 e406b20ca7 FEAT[BE] :Fixing fifo stock when marketing deleted 2026-02-02 21:11:27 +07:00
aguhh18 1c1f2f03aa FIX[BE] :remove unused product warehouse repository import and streamline stock consumption logic in consumeDeliveryStock method 2026-02-02 16:46:33 +07:00
aguhh18 ce108da847 FEAT[BE] :enhance production data calculations by adding TotalBirdSold and refining profit/loss metrics 2026-02-02 15:36:06 +07:00
ragilap e968b8ed9c Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into Fix/BE/Purchase-rejected 2026-02-02 15:24:11 +07:00
ragilap 96627e964f [FEAT/BE]Purchase rejected payload 2026-02-02 15:23:15 +07:00
ragilap 6e0ff557a8 [FEAT/BE]delete adjustment use in doc closing counting sapronak 2026-02-02 14:59:29 +07:00
ragilap f4790e56ea [FEAT/BE] update herautics for closing counting sapronak 2026-02-02 14:25:37 +07:00
ragilap 760b37449e [FEAT/BE] fix bug recording and closing counting sapronak 2026-02-02 14:08:29 +07:00
Hafizh A. Y. fb2a7a6676 Merge branch 'fix/inventory-stock' into 'development'
[FIX][BE]: add response stock to informasi stock product

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

See merge request mbugroup/lti-api!294
2026-02-02 06:17:48 +00:00
aguhh18 5b80081d05 FIX[BE] :add handling for empty input in ToSummaryDto function to return zeroed summary 2026-02-02 13:02:30 +07:00
giovanni 9bf33d2bae add response stock to informasi stock product 2026-02-02 12:15:41 +07:00
aguhh18 2cc89b31be Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-02-02 07:44:09 +07:00
aguhh18 3d92c4eb54 FEAT[BE] :refactor egg production data retrieval to use date parameter in GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds method 2026-02-02 07:42:22 +07:00
Adnan Zahir 2f5ddfe8a6 Merge branch 'staging' into 'production'
Staging 31 Jan 2026

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

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

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

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

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

See merge request mbugroup/lti-api!288
2026-01-31 10:40:07 +07:00
aguhh18 1a9936eaa1 FIX[BE] :remove obsolete db_lti_erp-202601271102-stg.sql file 2026-01-30 19:45:11 +07:00
giovanni c257a2cb28 add validation max size upload file 5 MB 2026-01-30 15:09:23 +07:00
giovanni 8a403e803c fix filter by kandang 2026-01-30 14:47:13 +07:00
Adnan Zahir a7611ad0b2 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!287
2026-01-30 14:43:56 +07:00
aguhh18 0707876a81 FEAT[BE] :update movement number format in GenerateMovementNumber method to include hyphen 2026-01-30 14:31:04 +07:00
Adnan Zahir c191776e6c Merge branch 'fix/daily-checklist' into 'development'
[FIX][BE]: fix search list daily checklist

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

See merge request mbugroup/lti-api!275
2026-01-30 13:27:16 +07:00
giovanni 4f9cd5131f Merge branch 'development' into feat/inventory-logs 2026-01-30 12:11:34 +07:00
giovanni 703c1548c9 fix search list daily checklist 2026-01-30 11:49:13 +07:00
Adnan Zahir 0042cf11ce Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!285
2026-01-30 11:42:14 +07:00
Adnan Zahir 616250d38b Merge branch 'fix/data-production-age' into 'development'
[FIX][BE]: Fix/data production age

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

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

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

See merge request mbugroup/lti-api!282
2026-01-29 19:12:27 +07:00
Adnan Zahir d1e3234f73 Merge branch 'feat/BE/sso-adjustment' into 'development'
Feat/be/sso adjustment

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

See merge request mbugroup/lti-api!257
2026-01-27 10:20:04 +07:00
Adnan Zahir 3c77aff413 Merge branch 'dev/teguh' into 'development'
FEAT[BE]: transfer to laying, stock transfer, refactor adjustment, fix  age closing penjualan, filter transfer to laying

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

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

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

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

See merge request mbugroup/lti-api!252
2026-01-26 11:24:41 +07:00
ragilap 571f6b4bf3 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/sso-adjustment 2026-01-25 22:16:38 +07:00
ragilap 76d980878d Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/sso-adjustment 2026-01-24 19:03:52 +07:00
Hafizh A. Y. fdd8e3ec31 Merge branch 'fix/hpp-calculate' into 'development'
[FIX][BE]: fix calculate sisa berat telur

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

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

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

See merge request mbugroup/lti-api!248
2026-01-24 09:30:49 +00:00
ragilap 8fbce5a01e Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/UUIT-Recording-closing-report-uniformity-dashboard 2026-01-24 14:20:30 +07:00
ragilap f4b2408698 [FIX/BE-US] fix recording stock and dashboard filtering 2026-01-24 14:20:14 +07:00
giovanni 8c84981812 adjust get weight remaining 2026-01-24 14:17:50 +07:00
Hafizh A. Y 458c8e0a91 fix(BE): edit customer, finance: bank optional, nominal minus, and filter 2026-01-24 13:35:13 +07:00
giovanni 4646bf5577 add filter location id 2026-01-24 13:34:21 +07:00
Adnan Zahir e60c08e09e Merge branch 'staging' into 'production'
Staging

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

See merge request mbugroup/lti-api!246
2026-01-24 12:14:46 +07:00
giovanni 8b1831fc73 adjust take weight remaining 2026-01-24 12:03:09 +07:00
Adnan Zahir 919dc5c2e8 Merge branch 'dev/teguh' into 'development'
FIX[BE]: module transfer, report penjualan, closing penjualan

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

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

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

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

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

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

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

See merge request mbugroup/lti-api!238
2026-01-22 09:00:17 +00:00
M1 AIR b1b50c3c01 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into development 2026-01-22 15:57:47 +07:00
M1 AIR c085888ca9 Update cicd no migrate prod 2026-01-22 15:57:16 +07:00
Adnan Zahir 41d3c21fef Merge branch 'staging' into 'production'
Staging

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

See merge request mbugroup/lti-api!236
2026-01-22 14:49:02 +07:00
Adnan Zahir 774c9b1d58 Merge branch 'fix/BE/US-281-adjustment-recording' into 'development'
[FIX/BE-US] fix day in recording

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

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

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

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

See merge request mbugroup/lti-api!229
2026-01-22 07:41:50 +00:00
aguhh18 551534d02a Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into HOTFIX/BE/marketing 2026-01-22 14:32:39 +07:00
aguhh18 202a8ffc66 HOTFIX[BE]: filter closing overhead by expense category "BOP" 2026-01-22 14:29:56 +07:00
giovanni 6bc5e7d293 fix get average bw 2026-01-22 14:19:16 +07:00
ragilap 2e0827dec5 [FIX/BE-US] debt-supplier only show receive and changes lunas 2026-01-22 14:15:26 +07:00
aguhh18 87973a6c9f HOTFIX[BE]: update total price calculation based on product flags for delivery and sales orders 2026-01-22 14:15:03 +07:00
kris ebac5f5a98 Merge branch 'development' into 'staging'
Fixing staging cicd

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

See merge request mbugroup/lti-api!233
2026-01-22 07:01:29 +00:00
M1 AIR 78a45b11e7 fixing pipeline 2026-01-22 13:59:52 +07:00
giovanni 58b29501c0 finishing common service calculate hpp 2026-01-22 13:54:32 +07:00
ragilap bac0361df5 [FIX/BE-US] debt-supplier only show receive purchase 2026-01-22 13:53:43 +07:00
Adnan Zahir c8912e503e Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!227
2026-01-22 13:52:03 +07:00
giovanni 645a97b460 remove max limit production result 2026-01-22 13:42:36 +07:00
ragilap eb2479cc41 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/Purchase-edit-qty 2026-01-22 13:36:53 +07:00
ragilap 04ec8560a7 [FIX/BE-US] adjustment recording and purchase stock log 2026-01-22 13:35:46 +07:00
Hafizh A. Y. 12240a9e2d Merge branch 'fix/mr-development-staging' into 'development'
Fix/mr development staging

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

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

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

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

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

See merge request mbugroup/lti-api!218
2026-01-22 02:47:35 +00:00
ragilap 5dbe9bb989 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/Purchase-edit-qty 2026-01-22 09:33:15 +07:00
aguhh18 e555cfa950 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-01-22 09:27:17 +07:00
ragilap 3a457ceee5 [FIX/BE-US] adjustment sso-location 2026-01-22 09:27:11 +07:00
M1 AIR 32a8557a3b Change rules cicd no conflicts 2026-01-21 15:56:58 +07:00
kris baa49b7cf1 Merge branch 'staging' into 'production'
Revert "Merge branch 'staging' into 'development'"

See merge request mbugroup/lti-api!225
2026-01-21 08:17:34 +00:00
M1 AIR 1818f5a295 merge: sync staging with production 2026-01-21 15:16:36 +07:00
aguhh18 a73b44808f FIX[BE]: fixing wrong index data on adjustment. change get from stocklogs to adjustment table 2026-01-21 15:16:30 +07:00
Adnan Zahir 69d9137e78 Merge branch 'development' into 'staging'
Development

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

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

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

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

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

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

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

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

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

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

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

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

See merge request mbugroup/lti-api!208
2026-01-20 02:23:42 +00:00
Hafizh A. Y. c793c3cf9a Merge branch 'dev/teguh' into 'development'
FIX[BE]: fixing BE

See merge request mbugroup/lti-api!207
2026-01-20 02:23:29 +00:00
aguhh18 b3e0410f5a Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-01-20 09:15:21 +07:00
Hafizh A. Y. a882d5a687 Merge branch 'Feat/BE/US-Closing_finance' into 'development'
[FIX][BE]: fixing filter on report penjualan and fix stock movement to not strict to not kandang warehouse

See merge request mbugroup/lti-api!206
2026-01-20 01:41:15 +00:00
Hafizh A. Y. c0848b6d2d Merge branch 'fix/closing-sapronak' into 'development'
[FIX][BE]: query outgoing sapronak

See merge request mbugroup/lti-api!205
2026-01-20 01:40:35 +00:00
aguhh18 b240478ed5 feat[BE]: Add notes field to Update validation and update approval logic in expense services 2026-01-19 17:44:10 +07:00
giovanni 3052497fc0 adjust grouping by project flock kandang 2026-01-19 17:05:43 +07:00
giovanni 71c62c5e02 [FIX][BE]: LSS390 2026-01-19 16:19:47 +07:00
aguhh18 768961d7d6 fix[BE]: Refactor GetAll method to improve query parameter handling and formatting 2026-01-19 14:39:43 +07:00
aguhh18 8cd9627a51 feat[BE]: Add requested_qty field to LayingTransferSource and update related logic for transfer operations 2026-01-19 14:34:08 +07:00
aguhh18 378d633ea4 feat[BE]: Enhance payment allocation logic to support FIFO consumption for sales transactions 2026-01-19 09:27:37 +07:00
aguhh18 fb193fc61f fix[BE]: Update GetAllWithFilters to enhance search functionality and join conditions 2026-01-18 21:26:31 +07:00
aguhh18 af7aabdec8 fix[BE]: Adjust validation for MarketingQuery limit to remove max constraint 2026-01-18 20:50:52 +07:00
aguhh18 bac36b4f00 fix[BE]: Update error messages in TransferService to provide clearer context in Indonesian 2026-01-18 20:36:33 +07:00
aguhh18 687d02313b feat[BE]: Update TransferRelationDTO and service search logic to include warehouse names 2026-01-18 19:46:09 +07:00
aguhh18 7d3602d829 feat[BE]: Enhance CreateOne method to validate project flock closing status and handle warehouse without kandang_id 2026-01-17 13:22:01 +07:00
aguhh18 533e9aca6f FIX[BE]: Fixing filter area and location 2026-01-17 12:20:40 +07:00
giovanni dcfb5e10b4 adjust max limit to 1000 2026-01-17 11:34:12 +07:00
giovanni fbeccf4cdc fix query outgoing sapronak 2026-01-17 11:01:08 +07:00
Adnan Zahir f2ae2cc731 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!204
2026-01-17 09:04:30 +07:00
Hafizh A. Y. eda50930e7 Merge branch 'Feat/BE/US-Closing_finance' into 'development'
[FEAT][BE]: Refactor closing services and add ClosingKeuanganService and add closing keuangan perkandang(the calculation not accurate yet)

See merge request mbugroup/lti-api!203
2026-01-17 01:21:13 +00:00
Hafizh A. Y. 0bc5480a1d Merge branch 'fix/production-data-dev' into 'development'
[FIX][BE]: fix api production result

See merge request mbugroup/lti-api!202
2026-01-17 01:20:31 +00:00
aguhh18 ef482dd1b9 feat[BE]: Add new ClosingKeuangan DTO and related mapper functions 2026-01-16 21:37:51 +07:00
aguhh18 302f0ed877 fix:[BE] Remove unnecessary filters and update profit loss calculation logic in ClosingKeuanganService 2026-01-16 21:34:49 +07:00
aguhh18 31c48ee1da feat[BE]: Add GetClosingKeuanganByKandang endpoint and related service methods 2026-01-16 20:53:47 +07:00
aguhh18 8ad11af9c9 feat: Refactor closing services and add ClosingKeuanganService
- Updated ClosingRoutes to include ClosingKeuanganService.
- Removed GetClosingKeuangan method from ClosingService interface and its implementation.
- Introduced new ClosingKeuanganService with GetClosingKeuangan method to handle financial logic.
- Implemented detailed logging and error handling in the new service.
- Added GetTotalWeightProducedFromUniformityByProjectFlockID method in RecordingRepository to support weight calculations.
- Enhanced the logic for fetching and classifying product usage data by flags.
- Built comprehensive DTO responses for HPP and Profit Loss sections.
2026-01-16 12:27:18 +07:00
giovanni 688d3fa757 fix api production result 2026-01-15 19:16:58 +07:00
Hafizh A. Y. 409652c15e Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!201
2026-01-15 11:55:47 +00:00
Hafizh A. Y. 08be60c229 Merge branch 'fix/warehouse-provided-location' into 'development'
fix(BE): warehouse provided location

See merge request mbugroup/lti-api!200
2026-01-15 11:53:20 +00:00
Hafizh A. Y. 50b19dc1c3 Merge branch 'fix/BE/Purchase-bop' into 'development'
[FIX/BE-US] adjustment purchase,closing hpp expedition,supplier filter flags

See merge request mbugroup/lti-api!199
2026-01-15 11:53:12 +00:00
Hafizh A. Y e770526c1a fix(BE): warehouse provided location 2026-01-15 18:51:32 +07:00
ragilap 77af262662 [FIX/BE-US] adjustment purchase,closing hpp expedition,supplier filter flags 2026-01-15 18:45:52 +07:00
Adnan Zahir 9d611b7492 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!198
2026-01-15 18:21:47 +07:00
Hafizh A. Y. 21ff1c8ab7 Merge branch 'fix/closing-sapronak-data-produksi' into 'development'
[FIX][BE]: api closing sapronak and data produksi

See merge request mbugroup/lti-api!197
2026-01-15 11:18:49 +00:00
giovanni 2ca84ecffe fixing api closing sapronak and data produksi 2026-01-15 18:16:23 +07:00
Adnan Zahir f13e4f907c Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!196
2026-01-15 17:59:24 +07:00
Hafizh A. Y. 4d334e8d5c Merge branch 'feat/hpp-harian' into 'development'
[FEAT][BE]: add hpp harian

See merge request mbugroup/lti-api!195
2026-01-15 10:54:29 +00:00
Hafizh A. Y. 62522a751f Merge branch 'FEAT/BE/report_customer_payment' into 'development'
[Feat][BE]: creating report customer payment API

See merge request mbugroup/lti-api!190
2026-01-15 10:53:58 +00:00
giovanni 62ccc2e5d6 adjust avg weight 2026-01-15 16:48:37 +07:00
aguhh18 13fc246f21 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into FEAT/BE/report_customer_payment 2026-01-15 16:20:37 +07:00
giovanni 89293a843e adjust api hpp kandang 2026-01-15 16:07:45 +07:00
Adnan Zahir a5af469865 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!192
2026-01-15 15:58:37 +07:00
Hafizh A. Y. 8efe9b668b Merge branch 'fix/nonstock-supplier' into 'development'
fix(BE): remove supplier price in master nonstock

See merge request mbugroup/lti-api!194
2026-01-15 08:52:11 +00:00
Hafizh A. Y. 89480deeb0 Merge branch 'feat/BE/US-281-adjustment_recording' into 'development'
[FIX/BE-US] add response warehouse and project flock kandang

See merge request mbugroup/lti-api!193
2026-01-15 08:51:57 +00:00
Hafizh A. Y. 4146342120 Merge branch 'feat/daily-checklist-permission' into 'development'
[FEAT][BE]: add daily checklist permission

See merge request mbugroup/lti-api!191
2026-01-15 08:51:32 +00:00
Hafizh A. Y. b375fb964e Merge branch 'feat/production-result' into 'development'
[FEAT][BE]: adjust api production-result

See merge request mbugroup/lti-api!188
2026-01-15 08:50:33 +00:00
Hafizh A. Y fe002c9602 fix(BE): remove supplier price in master nonstock 2026-01-15 15:49:15 +07:00
ragilap f1032b44d1 [FIX/BE-US] adjustment recording 2026-01-15 15:42:57 +07:00
ragilap a3e9017e29 [FIX/BE-US] changes role to user and query 2026-01-15 14:43:59 +07:00
ragilap 7f2401311b [FIX/BE-US] add response warehouse and project flock kandang 2026-01-15 13:48:00 +07:00
kris 3f4d6c630a Update .gitlab-ci.yml file 2026-01-15 06:46:07 +00:00
aguhh18 8792161c02 feat[BE]: rename Price field to UnitPrice in CustomerPaymentReportRow for clarity 2026-01-15 10:58:00 +07:00
giovanni 37c26d5877 add daily checklist permission 2026-01-15 10:45:13 +07:00
aguhh18 c316a6d7a9 feat[BE]: add address field to CustomerRelationDTO and refactor payment report functions for improved clarity and structure 2026-01-15 10:41:44 +07:00
aguhh18 3a89e18b16 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into FEAT/BE/report_customer_payment 2026-01-14 20:10:31 +07:00
aguhh18 c6dc94a4e1 feat[BE]: add permission requirement for customer payment report route 2026-01-14 20:06:41 +07:00
aguhh18 aeb5433346 feat[BE]: refine customer payment report structure by removing unused fields and enhancing query logic for better performance 2026-01-14 20:00:44 +07:00
ragilap 549e283757 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/sso-adjustment 2026-01-14 17:01:33 +07:00
kris cad15bcd78 Update .gitlab-ci.yml file 2026-01-14 09:46:39 +00:00
giovanni e004354420 adjust api production-result 2026-01-14 16:20:59 +07:00
kris e0ff6e6d79 Update .gitlab-ci.yml file 2026-01-14 08:26:07 +00:00
aguhh18 804ff45dbd feat[BE]: enhance customer payment report with vehicle numbers and pickup info, add date filtering 2026-01-14 15:15:29 +07:00
kris 9f1c153841 Merge branch 'development' into 'staging'
Edit .gitignore (Tom haye)

See merge request mbugroup/lti-api!184
2026-01-14 07:37:47 +00:00
kris 2a884a8d09 Edit .gitignore (Tom haye) 2026-01-14 07:35:57 +00:00
kris dbf72c7248 Delete .air.toml 2026-01-14 07:06:37 +00:00
aguhh18 7daa509cd0 feat[BE]: update customer payment report to support multiple customer IDs and nullable aging days 2026-01-14 14:06:34 +07:00
kris 894fa0b22a Update .gitlab-ci.yml file 2026-01-14 06:55:28 +00:00
Adnan Zahir d680196919 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!180
2026-01-14 13:48:25 +07:00
Adnan Zahir 9fc2d0556e Merge branch 'chore/test-staging' into 'development'
chore: test staging from development

See merge request mbugroup/lti-api!179
2026-01-14 13:46:38 +07:00
Adnan Zahir c2a89910fb chore: test staging from development 2026-01-14 13:46:15 +07:00
ragilap 32772a63c8 Revert "Revert "[FIX/BE-US]add feature restrict by location and areas in roles""
This reverts commit 26bf7f165e.
2026-01-14 13:34:44 +07:00
ragilap 26bf7f165e Revert "[FIX/BE-US]add feature restrict by location and areas in roles"
This reverts commit dff9e73ab1.
2026-01-14 13:30:48 +07:00
ragilap dff9e73ab1 [FIX/BE-US]add feature restrict by location and areas in roles 2026-01-14 13:27:52 +07:00
Hafizh A. Y. 1847e5590a Merge branch 'feat/informasi-umum' into 'development'
adjust api informasi umum filter per kandang

See merge request mbugroup/lti-api!177
2026-01-14 06:21:21 +00:00
Hafizh A. Y. 57094f664c Merge branch 'feat/BE/US-281-adjustment_recording' into 'development'
Feat/be/us 281 adjustment recording

See merge request mbugroup/lti-api!174
2026-01-14 06:20:57 +00:00
Hafizh A. Y. f8b6e12d16 Merge branch 'dev/teguh' into 'development'
FIX[BE]: fixing error on report marketing dto

See merge request mbugroup/lti-api!178
2026-01-14 06:19:52 +00:00
kris e12c34db13 Update .gitlab-ci.yml file 2026-01-14 06:18:47 +00:00
aguhh18 3012d260ec FIX[BE]: fixing error on report marketing dto 2026-01-14 11:57:56 +07:00
aguhh18 f6e872c0aa feat[BE]: implement customer payment report retrieval with pagination and filtering 2026-01-14 11:46:39 +07:00
MacBook Air M1 8a639f127c adjust api informasi umum filter per kandang 2026-01-14 11:41:47 +07:00
Hafizh A. Y. 2acaa10b60 Merge branch 'fix/air.toml' into 'development'
fix(BE): add .air.toml

See merge request mbugroup/lti-api!176
2026-01-14 04:22:29 +00:00
MacBook Air M1 bd9d41e161 fix(BE): add .air.toml 2026-01-14 11:20:42 +07:00
Hafizh A. Y. 06d8d0b795 Merge branch 'feat/price-product-supplier' into 'development'
feat(BE): price-product-supplier

See merge request mbugroup/lti-api!160
2026-01-14 02:17:42 +00:00
ragilap 1d5e7b6e1a Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-281-adjustment_recording 2026-01-14 02:09:57 +07:00
Hafizh A. Y. da190f1b05 Merge branch 'feat/BE/Rekapitulasi-hutang-supplier' into 'development'
[Feat/be] Adjustment Counting debt supplier

See merge request mbugroup/lti-api!173
2026-01-13 17:40:18 +00:00
Hafizh A. Y. c8905eb715 Merge branch 'feat/BE/sapronak/data-produksi' into 'development'
Feat/be/sapronak/data produksi

See merge request mbugroup/lti-api!170
2026-01-13 17:39:33 +00:00
aguhh18 7f1d796b65 fix[BE]: correct total price calculation in delivery and sales order services 2026-01-13 22:50:58 +07:00
aguhh18 6c7ff3f415 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into FEAT/BE/report_customer_payment 2026-01-13 20:59:50 +07:00
ragilap 03fbf7f4b7 production_standard 2026-01-13 20:06:37 +07:00
ragilap 7545e9b37d [FIX/BE-US] add ignore for dockerfile.local 2026-01-13 19:57:47 +07:00
ragilap 69f38bf16a Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/Rekapitulasi-hutang-supplier 2026-01-13 19:55:02 +07:00
ragilap 5730053e04 [FIX/BE-US-390] changes counting debt supplier 2026-01-13 19:54:47 +07:00
MacBook Air M1 b087a703ef resolve conflict to development 2026-01-13 16:47:16 +07:00
Adnan Zahir 00edcb6add Merge branch 'chore/test-container-versioning' into 'development'
chore: test image versioning 2

See merge request mbugroup/lti-api!171
2026-01-13 16:47:12 +07:00
Adnan Zahir de6580d11c chore: test image versioning 2 2026-01-13 16:46:49 +07:00
Hafizh A. Y. dcd6008946 Merge branch 'FEAT/BE/report-penjualan' into 'development'
[FEAT][BE]: add some filter on report penjualan API and fixing some error

See merge request mbugroup/lti-api!167
2026-01-13 09:26:01 +00:00
Hafizh A. Y. 711f58abae Merge branch 'feat/BE/US-390-Dashboard' into 'development'
Feat/be/us 390 dashboard changes calculate kilo units

See merge request mbugroup/lti-api!166
2026-01-13 09:25:49 +00:00
Hafizh A. Y. ffd3c905fe Merge branch 'FEAT/BE/Closing_overhead_perkandang' into 'development'
feat[BE]: enhance GetOverhead functionality with project flock kandang count...

See merge request mbugroup/lti-api!165
2026-01-13 09:25:38 +00:00
Hafizh A. Y. 6e4a8617da Merge branch 'Feat/BE/Closing_Penjualan_perkandang' into 'development'
[FEAT][BE] : create closing penjualan perkandang API

See merge request mbugroup/lti-api!164
2026-01-13 09:25:24 +00:00
Adnan Zahir 8a57d5d675 Merge branch 'chore/test-container-versioning' into 'development'
chore: test image versioning

See merge request mbugroup/lti-api!168
2026-01-13 16:18:45 +07:00
Adnan Zahir 5de81f6315 chore: test image versioning 2026-01-13 16:18:15 +07:00
MacBook Air M1 e50dd096a4 Merge branch 'development' into dev/gio 2026-01-13 16:15:15 +07:00
MacBook Air M1 7551d11888 add filter by kandang id sapronak 2026-01-13 16:14:11 +07:00
Adnan Zahir 7444cfac31 Merge branch 'staging' into 'development'
Staging 13 January 2026

See merge request mbugroup/lti-api!163
2026-01-13 16:10:56 +07:00
aguhh18 1b5b5bc847 feat[BE]: add MarketingType filter to marketing reports and update related validations 2026-01-13 16:10:27 +07:00
ragilap 5d7b613ffc [FIX/BE-US-281] changes calculate fcr egg 2026-01-13 15:39:20 +07:00
ragilap 33e89d65ab [FIX/BE-US-281] changes calculate fcr egg 2026-01-13 15:37:54 +07:00
MacBook Air M1 0f4cc6e379 adjust api closing data produksi 2026-01-13 15:32:43 +07:00
MacBook Air M1 590df26a1f adjust api closing production data 2026-01-13 14:43:37 +07:00
ragilap ce7ce778fd [FIX/BE-US-281] add response validation if weeks not have production standart 2026-01-13 14:39:59 +07:00
ragilap eaa208f733 [FIX/BE-US-281] response recording and add payload record_at only in createOne 2026-01-13 14:11:53 +07:00
aguhh18 b088eebac5 feat[BE]: enhance GetOverhead functionality with project flock kandang count mapping and update related DTOs 2026-01-13 13:36:08 +07:00
aguhh18 3c10866208 feat[BE]: add GetOverheadByProjectFlockKandang endpoint and update related services 2026-01-13 13:20:06 +07:00
kris cfbe431222 Update .gitlab-ci.yml file 2026-01-13 04:43:44 +00:00
aguhh18 f7a392be52 feat[BE]: add GetPenjualanByProjectFlockKandang endpoint and update related services 2026-01-13 11:37:23 +07:00
kris 4c434899aa Update .gitlab-ci.yml file 2026-01-13 04:36:34 +00:00
kris 7fd90f3268 Update .gitlab-ci.yml file 2026-01-13 04:19:00 +00:00
kris d26c2dba3f Update .gitlab-ci.yml file 2026-01-13 04:15:08 +00:00
M1 AIR f8415ea15d Update gitlab 2026-01-13 10:59:51 +07:00
M1 AIR 64fe845128 Update CICD 2026-01-13 10:46:55 +07:00
aguhh18 4bd8319e3b FIX[BE] : fixing typografical error on report marketing 2026-01-13 10:16:28 +07:00
aguhh18 bba2dec8c6 FEAT[BE] :update route 2026-01-13 09:52:25 +07:00
Hafizh A. Y. f7b70d4b14 Merge branch 'feat/BE/Rekapitulasi-hutang-supplier' into 'development'
[FIX/BE] adjustment response

See merge request mbugroup/lti-api!162
2026-01-13 01:55:59 +00:00
Hafizh A. Y. 9f28294dc3 Merge branch 'FIX/BE/fix_chickin_can't_recording_because_replenish_isn't_correct_for_one_flag_product' into 'development'
FIX[BE] ; fixing chikin replenish. and other logic

See merge request mbugroup/lti-api!161
2026-01-13 01:55:42 +00:00
ragilap 6a166ceb86 [FIX/BE-US-390] dashboard statistic hpp global and avg seling price not refrence to filter 2026-01-13 00:15:48 +07:00
aguhh18 f0b4fe916c FEAT[BE] ;: inisiate customer payment report route and related DTOs 2026-01-12 20:00:49 +07:00
ragilap f37bf4d22d Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/Rekapitulasi-hutang-supplier 2026-01-12 16:37:31 +07:00
ragilap ac5edb36e7 [FIX/BE] adjustment response 2026-01-12 16:28:03 +07:00
Hafizh A. Y 8fab5d7d91 feat(BE): price-product-supplier 2026-01-12 14:54:14 +07:00
Hafizh A. Y. 5ddfb2c745 Merge branch 'Feat/BE/Expense_adjust_approval_flow' into 'development'
[Feat][BE}: expense adjust approval flow get rid approval manager change to head area and add business unit vice president approval

See merge request mbugroup/lti-api!159
2026-01-12 06:39:22 +00:00
Hafizh A. Y. 5cfa4d4a59 Merge branch 'fix/daily-checklist' into 'development'
fix route daily checklist

See merge request mbugroup/lti-api!158
2026-01-12 06:38:51 +00:00
aguhh18 80b2cafd2f FIX[BE] ; fixing chikin replenish. and other logic 2026-01-12 13:29:24 +07:00
MacBook Air M1 b47f26d448 adjust max limit location and kandang 2026-01-12 11:30:57 +07:00
M1 AIR 2f22182605 Merge branch 'staging' of https://gitlab.com/mbugroup/lti-api into staging 2026-01-12 11:16:43 +07:00
M1 AIR e2d352721c Merge from development 2026-01-12 11:15:59 +07:00
M1 AIR 068fe4329e Merge remote-tracking branch 'origin/development' into staging 2026-01-12 11:13:24 +07:00
MacBook Air M1 15be8dcbea fix route daily checklist 2026-01-12 11:09:32 +07:00
Hafizh A. Y. 041e8763ac Merge branch 'fix/not-showed-product-supplier' into 'development'
fix(BE): is visible to true in product service

See merge request mbugroup/lti-api!157
2026-01-12 03:39:59 +00:00
Hafizh A. Y. 644e9911e4 Merge branch 'FIX/BE/Case-sensitive' into 'development'
[FIX/BE] adjust case sensitive search To Incase sensitive

See merge request mbugroup/lti-api!156
2026-01-12 03:39:48 +00:00
Hafizh A. Y. bb04cb53d9 Merge branch 'feat/BE/Rekapitulasi-hutang-supplier' into 'development'
feat(BE):Rekapitulasi hutang supplier

See merge request mbugroup/lti-api!155
2026-01-12 03:39:26 +00:00
Hafizh A. Y. 048e607290 Merge branch 'feat/daily-checklist-upload-documents' into 'development'
[FEAT][BE]: add api upload documents daily checklist

See merge request mbugroup/lti-api!154
2026-01-12 03:39:06 +00:00
Hafizh A. Y. 18441eb19f Merge branch 'feat/BE/US-390-Dashboard' into 'development'
[FEAT/BE][US-390 dashboard calculation standart]

See merge request mbugroup/lti-api!152
2026-01-12 03:38:51 +00:00
Hafizh A. Y. 526e14f26e Merge branch 'FIX/BE/Transfer_to_laying' into 'development'
[FIX][BE]: fixing transfer to laying qty doesn't  listed on product warehouse and fixing wrong implementation of fifo stock on laying transfer

See merge request mbugroup/lti-api!151
2026-01-12 03:38:27 +00:00
Hafizh A. Y 539081ce99 fix(BE): is visible to true in product service 2026-01-12 10:37:55 +07:00
MacBook Air M1 d568b87e01 adjust response api summary daily checklist 2026-01-12 10:37:33 +07:00
aguhh18 9515848d8f feat(BE): update approval flow to use head area instead of manager 2026-01-12 10:11:31 +07:00
ragilap c15ff8a211 [FIX/BE] add percent rasio in statistic dashboard 2026-01-12 01:15:31 +07:00
ragilap d1d94357cf [FIX/BE] add clamp maximum value 2026-01-12 00:27:53 +07:00
ragilap 67f5165bfb [FIX/BE] adjust case sensitive search 2026-01-11 23:47:28 +07:00
ragilap 1217f34dcd feat(BE):change standart egg in fcr master data 2026-01-11 22:19:20 +07:00
MacBook Air M1 ae41422776 add api upload documents daily checklist 2026-01-11 22:02:21 +07:00
aguhh18 3978951d8f Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into Feat/BE/Expense_adjust_approval_flow 2026-01-11 21:19:35 +07:00
aguhh18 3422fceec7 feat(BE-ExpenseApproval): add unit vice president approval step and permissions 2026-01-11 20:10:19 +07:00
ragilap 09f1b29359 feat(BE):Rekapitulasi hutang supplier 2026-01-11 19:41:21 +07:00
ragilap 167d18fe87 feat(BE-309): add permission dashboard 2026-01-11 19:26:25 +07:00
ragilap 473f4504ea feat(BE-309): changes COMPARASION TO COMPARISON 2026-01-11 19:09:43 +07:00
ragilap dc7dc0ba47 adjustment meta 2026-01-11 18:54:05 +07:00
ragilap a54129866e feat(BE-390): adjustment calculate dashboard 2026-01-11 18:35:23 +07:00
ragilap d40243be4b Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-390-Dashboard 2026-01-11 18:26:40 +07:00
ragilap 525ff650f2 feat(BE-390): calculation dashboard 2026-01-11 15:40:47 +07:00
aguhh18 c1e9b5a975 FIX[BE]: add laying transfer source and target repositories to transfer laying service 2026-01-11 13:30:19 +07:00
aguhh18 272367d8ef FIX[BE]: fixing transfer to laying and implement correct fifo stock 2026-01-11 12:51:37 +07:00
Hafizh A. Y. d3c7d65bf5 Merge branch 'fix/not-showed-product-supplier' into 'development'
[FIX][BE] Permission in payment route

See merge request mbugroup/lti-api!150
2026-01-11 01:44:41 +00:00
Hafizh A. Y. 944fd860a3 Merge branch 'feat/BE/US-74-76-78-278/adjustment_recording_purchase_project_flock' into 'development'
[FIX][BE-74-76-78-278]:adjustment project flock,recording,purchase getall

See merge request mbugroup/lti-api!149
2026-01-11 01:44:25 +00:00
Hafizh A. Y af79db8726 fix(BE): permission in payment route 2026-01-11 08:41:45 +07:00
ragilap b42ca5e6fb feat(BE-74-76-78-278):delete unused code recording 2026-01-10 21:34:19 +07:00
ragilap 3b2c6f16c3 feat(BE-74-76-78-278):adjustment project flock,recording,purchase getall 2026-01-10 21:22:54 +07:00
Hafizh A. Y. 359e982e76 Merge branch 'fix/not-showed-product-supplier' into 'development'
[FIX][BE] Not showed supplier in master data product

See merge request mbugroup/lti-api!148
2026-01-10 11:41:54 +00:00
Hafizh A. Y bc0bf7fe16 fix(BE): not showed supplier in master data product 2026-01-10 18:25:04 +07:00
Hafizh A. Y. 70a7b1b888 Merge branch 'fix/master-data-internal-server-error' into 'development'
[FIX][BE]: code 500 when delete master data foreign to others table

See merge request mbugroup/lti-api!147
2026-01-09 09:44:21 +00:00
Hafizh A. Y 17d55bd2c0 fix(BE): fix code 500 when delete master data foreign to others table 2026-01-09 16:43:20 +07:00
Hafizh A. Y. 9cc86df1ed Merge branch 'fix/seeder-and-finance' into 'development'
[FIX][BE]: Add party account number in payments

See merge request mbugroup/lti-api!146
2026-01-09 09:06:40 +00:00
Hafizh A. Y b7914e8294 fix(BE): add party account number in payments 2026-01-09 16:03:41 +07:00
kris d33119661a Update .gitlab-ci.yml file 2026-01-09 08:37:55 +00:00
Hafizh A. Y 8a57d439dc unfinish: seeder and fix migration 2026-01-09 14:18:28 +07:00
kris 3d76854273 Update .gitlab-ci.yml file 2026-01-09 04:27:45 +00:00
kris b7a3882f20 Update .gitlab-ci.yml file 2026-01-09 04:19:24 +00:00
M1 AIR 29933a5df9 change cicd 2026-01-09 11:17:49 +07:00
M1 AIR f8aee4be7b penyesuaian flow cicid 2026-01-09 10:58:11 +07:00
Hafizh A. Y. 4ee5bf3628 Merge branch 'feat/BE/US-281-uniformity' into 'development'
Feat/be/us 281 uniformity

See merge request mbugroup/lti-api!145
2026-01-09 03:52:26 +00:00
Hafizh A. Y. 0f06dff761 Merge branch 'dev/teguh' into 'development'
FEAT[BE]: add expense to transfer stock, make  document optional on transfer stock,  fixing closing penjualan bad request

See merge request mbugroup/lti-api!144
2026-01-09 03:51:48 +00:00
Hafizh A. Y. 0629c5ccf6 Merge branch 'dev/daily-checklist' into 'development'
adjust validate patch daily checklist

See merge request mbugroup/lti-api!143
2026-01-09 03:44:27 +00:00
ragilap 43eb1df118 feat(BE-281): fixing duplicate 2026-01-09 10:06:22 +07:00
ragilap 338312edd1 feat(BE-281): unique uniformity weeks 2026-01-09 10:04:31 +07:00
ragilap f7522636e2 feat(BE-281): unique uniformity weeks 2026-01-09 09:27:49 +07:00
aguhh18 b11f03dfda feat(BE): fixing wrong perhitungan biaya 2026-01-09 09:15:53 +07:00
aguhh18 76e65704d7 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-01-08 22:02:59 +07:00
aguhh18 857a3c284b feat(BE): implement movement number generation and refactor transfer creation logic 2026-01-08 22:00:32 +07:00
aguhh18 5606b9c4a3 FIX(BE): fix closing marketing 500 2026-01-08 20:44:56 +07:00
aguhh18 7af78d04dd feat(BE): add file size validation and improve document indexing for transfer creation 2026-01-08 18:52:12 +07:00
aguhh18 2650e919e7 feat(BE): implement expense tracking for stock transfers and enhance related services 2026-01-08 15:14:06 +07:00
MacBook Air M1 c729067ab5 adjust validate pactch daily checklist 2026-01-08 15:08:40 +07:00
Hafizh A. Y. 7b2d3ae025 Merge branch 'dev/daily-checklist' into 'development'
adjust get all phase activity

See merge request mbugroup/lti-api!142
2026-01-08 07:25:06 +00:00
MacBook Air M1 f079bee92a adjust get all phase activity 2026-01-08 13:37:53 +07:00
Hafizh A. Y. 64e8de2344 Merge branch 'feat/BE/US-281-uniformity' into 'development'
feat(BE-281): add types uniformity

See merge request mbugroup/lti-api!141
2026-01-08 06:12:41 +00:00
Hafizh A. Y. 2be9ae36c1 Merge branch 'dev/daily-checklist' into 'development'
Adjust limit for get all employee

See merge request mbugroup/lti-api!140
2026-01-08 06:12:06 +00:00
ragilap 6c08fe23ca feat(BE-281): add types uniformity 2026-01-08 12:40:36 +07:00
MacBook Air M1 f1f7edb9ab Adjust limit for get all employee 2026-01-08 11:50:34 +07:00
Hafizh A. Y. 8a64300ddd Merge branch 'dev/daily-checklist' into 'development'
add master data config checklist

See merge request mbugroup/lti-api!139
2026-01-08 02:18:22 +00:00
Hafizh A. Y. 9164550263 Merge branch 'feat/BE/US-281-uniformity' into 'development'
feat(BE-281): fixing recording error, fixing limit upload uniformity and...

See merge request mbugroup/lti-api!138
2026-01-08 02:17:49 +00:00
MacBook Air M1 a4840fc98a add master data config checklist 2026-01-07 21:37:51 +07:00
ragilap a2d2c4269a feat(BE-281): fixing recording error, fixing limit upload uniformity and purchase, add filter and statistic uniformity 2026-01-07 20:26:27 +07:00
Hafizh A. Y. 90f363bfdb Merge branch 'dev/daily-checklist' into 'development'
add module daily checklist

See merge request mbugroup/lti-api!137
2026-01-07 10:55:49 +00:00
MacBook Air M1 c3f8ae5887 add api daily checklist report 2026-01-07 17:39:17 +07:00
Hafizh A. Y. a7a784970d Merge branch 'dev/teguh' into 'development'
FIX[BE]: fix transfer to laying, fix delete biaya, fix chickin, fix nominal expense, and other

See merge request mbugroup/lti-api!136
2026-01-07 07:25:09 +00:00
aguhh18 18b0663dc6 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-01-07 14:03:08 +07:00
aguhh18 375e057e7c feat(BE): enhance chickin and transfer laying services with product warehouse validation and stockable support 2026-01-07 14:02:39 +07:00
aguhh18 9336289573 feat(BE): add excluded stockables support in FIFO allocation and fetching methods 2026-01-07 13:54:55 +07:00
aguhh18 76d5b6b69a feat(BE): enhance ProjectFlockKandang structure and approval fetching methods 2026-01-07 13:39:54 +07:00
MacBook Air M1 e545047165 add api summary and update status 2026-01-07 12:02:44 +07:00
MacBook Air M1 42aa6829c5 add api detail daily checklist 2026-01-07 10:36:40 +07:00
aguhh18 0a84e427c1 FIX[BE]: fixing bug transfer to laying, delet biaya, nominal expesen e, chickin 2026-01-07 09:27:39 +07:00
MacBook Air M1 dded9e807b add api check uncheck assignment 2026-01-06 23:59:16 +07:00
M1 AIR cad91957b3 Merge development into staging 2026-01-06 18:58:48 +07:00
Hafizh A. Y. fca2d63c6e Merge branch 'dev/gio' into 'development'
fix init population

See merge request mbugroup/lti-api!135
2026-01-06 11:27:13 +00:00
MacBook Air M1 f5a016b74b adjust init population 2026-01-06 17:23:06 +07:00
Hafizh A. Y. 82a7bada05 Merge branch 'dev/gio' into 'development'
feat[BE]: api production result

See merge request mbugroup/lti-api!131
2026-01-06 10:11:25 +00:00
Hafizh A. Y. c6626cb6f5 Merge branch 'development' into 'dev/gio'
# Conflicts:
#   internal/modules/repports/route.go
2026-01-06 10:09:45 +00:00
Hafizh A. Y. ebfa88e721 Merge branch 'dev/daily-checklist' into 'development'
add module daily checklist and master data employee, phase

See merge request mbugroup/lti-api!134
2026-01-06 10:09:12 +00:00
Hafizh A. Y. 705138795c Merge branch 'feat/BE/US-281-adjustment_recording' into 'development'
feat(BE-281): adjustment recording to cascade

See merge request mbugroup/lti-api!133
2026-01-06 10:08:54 +00:00
Hafizh A. Y. 538372a43a Merge branch 'feat/BE/US-281-uniformity' into 'development'
[FIX/BE-281] adjustment sso redirect,adjustment response closing,adjustment uniformity

See merge request mbugroup/lti-api!132
2026-01-06 10:08:24 +00:00
MacBook Air M1 3bd0602525 add daily checklist module;adjust master data;adjust migration 2026-01-06 17:03:55 +07:00
ragilap 7a26ca5fe5 feat(BE-281): adjustment recording to cascade 2026-01-06 17:01:09 +07:00
aguhh18 a08466a28e FIX(BE): update foreign key constraints to use ON DELETE NO ACTION for expense and marketing tables 2026-01-06 12:43:52 +07:00
ragilap 1bdaf63763 feat(BE-281): adjustment sso redirect,adjustment response closing,adjustment uniformity 2026-01-06 12:02:19 +07:00
aguhh18 d8fb427734 feat(BE): add grand total calculation to ExpenseListDTO and update CreateOne method in expense service 2026-01-06 11:12:41 +07:00
aguhh18 c9ebd88e9d Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2026-01-06 10:10:37 +07:00
aguhh18 0c6d42070a feat(BE): add items field to TransferDeliveryDTO in ToTransferDetailDTO function 2026-01-06 09:03:39 +07:00
MacBook Air M1 b1996be24c add module master phase activity 2026-01-05 22:25:46 +07:00
MacBook Air M1 4a08be1f55 add module master data phases 2026-01-05 19:59:03 +07:00
MacBook Air M1 9f840f2650 adjust patch employee 2026-01-05 17:49:44 +07:00
MacBook Air M1 80109b77db adjust api get all employees 2026-01-05 17:32:41 +07:00
MacBook Air M1 df504e3ff0 add migration;add api create employee 2026-01-05 17:17:25 +07:00
Hafizh A. Y. c1a162b4d4 Merge branch 'fix/sapronak-dev' into 'development'
adjust api closing tap sapronak

See merge request mbugroup/lti-api!130
2026-01-03 18:04:50 +00:00
Hafizh A. Y. 1101879039 Merge branch 'fix/BE-Document_s3' into 'development'
feat(BE): fix fifo system recording and uniformity dto

See merge request mbugroup/lti-api!129
2026-01-03 18:04:24 +00:00
ragilap 8de33a0f24 feat(BE): fix delete project flock budget and uniformity, and fix uniformity with update purchase document 2026-01-02 20:43:57 +07:00
MacBook Air M1 1348483b1c adjust api closing tap sapronak 2026-01-02 13:19:11 +07:00
MacBook Air M1 8725d79f8f Merge branch 'feat/BE/Sprint-8' into dev/gio 2026-01-02 12:25:50 +07:00
ragilap 2f8f84cb0d Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE-Document_s3 2026-01-02 12:05:28 +07:00
ragilap cc5a58b6d1 feat(BE): sso delete and fix response too many request 2026-01-02 12:04:50 +07:00
MacBook Air M1 39909d1c2e first commit api production-result 2026-01-02 11:24:26 +07:00
ragilap fe51f33ab4 feat(BE): fixing fifo system recording 2025-12-31 19:30:04 +07:00
ragilap e0dd2799fc feat(BE): fix fifo system recording and uniformity dto 2025-12-31 15:10:06 +07:00
aguhh18 556540e97f Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-31 14:04:25 +07:00
aguhh18 e421307965 feat(BE): add validation for product category based on flock category in CreateOne method 2025-12-31 13:29:46 +07:00
Hafizh A. Y. 394eb0f363 Merge branch 'dev/fix-route' into 'development'
fix rename route api closing data production

See merge request mbugroup/lti-api!127
2025-12-31 06:16:55 +00:00
MacBook Air M1 47d497d6b0 fix rename route api closing data production 2025-12-31 13:15:02 +07:00
aguhh18 1b5437bc01 FIX[BE]Lock chickins, accumulate pending qty, use qty key 2025-12-31 13:09:13 +07:00
aguhh18 7d6573fabd FIC[BE]Use qty column for warehouse updates 2025-12-31 13:05:54 +07:00
aguhh18 ce083bccdc feat(BE): add project flock ID to product warehouse creation for approval process 2025-12-31 12:59:17 +07:00
aguhh18 dc4729c3b9 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-31 11:44:02 +07:00
aguhh18 bec6a93152 FIX[BE]Stop adjusting target product warehouse quantity
Avoid double counting: population entries and ConsumeChickinStocks already update inventory. Pullet/layer for the flock will
be added via other flows (purchase, transfer, etc.)
2025-12-31 11:43:03 +07:00
Hafizh A. Y. 3a1a2b436d Merge branch 'fix/BE-Document_s3' into 'development'
feat(BE): add function read and download in document

See merge request mbugroup/lti-api!126
2025-12-31 04:41:53 +00:00
ragilap 9d285869f5 feat(BE): add function read and download in document 2025-12-31 11:39:53 +07:00
aguhh18 42853aaac0 fix[BE]Reset chickin pending usage and skip zero entries 2025-12-31 11:34:57 +07:00
aguhh18 610555c3cf Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-31 10:29:32 +07:00
aguhh18 c60c40af03 feat(be): add GetByProjectFlockKandangIDForUpdate method to lock chickin rows and prevent race conditions 2025-12-31 10:27:15 +07:00
Hafizh A. Y. 4e2724a702 Merge branch 'dev/teguh' into 'development'
feat[BE]: add get all nonstock by supplier id on nonstock get all

See merge request mbugroup/lti-api!125
2025-12-31 03:02:48 +00:00
Hafizh A. Y. 953756c15c Merge branch 'fix/BE/US-278-purchase_adjustment_createOne' into 'development'
Fix/be/us 278 purchase adjustment createone

See merge request mbugroup/lti-api!123
2025-12-31 03:01:35 +00:00
Hafizh A. Y. 2749e44439 Merge branch 'feat/BE/US-281-adjustment_recording' into 'development'
feat(BE-281): adjustment recording table with handhouse and deleting weight...

See merge request mbugroup/lti-api!122
2025-12-31 03:01:09 +00:00
ragilap b8c0b0c37d feat(BE-278): add std for max_depletion 2025-12-31 09:44:20 +07:00
Hafizh A. Y. acbf52a5e1 Merge branch 'feat/BE/US-281-uniformity' into 'development'
feat(BE-281): adjustment bug erorr 500 if 404 record projectflock

See merge request mbugroup/lti-api!124
2025-12-31 02:41:42 +00:00
aguhh18 0fc560b91c fix(be): update nonstock query to use SupplierID as a non-pointer type 2025-12-31 09:40:05 +07:00
Hafizh A. Y. 2d098cb6b1 Merge branch 'feat/BE/US-281-uniformity' into 'feat/BE/Sprint-8'
feat(BE-281): uniformity and create project flock triger... unfinished s3 read

See merge request mbugroup/lti-api!121
2025-12-31 02:20:01 +00:00
Hafizh A. Y. d35d0bbe6b Merge branch 'dev/teguh' into 'development'
fix(BE): fix error get location id when only attach to location in expense

See merge request mbugroup/lti-api!119
2025-12-31 02:18:27 +00:00
ragilap d9afd2913e feat(BE-278): adjustment_recording dto 2025-12-31 09:13:55 +07:00
ragilap dbaee73134 feat(BE-278): fix error purchase product warehouse 2025-12-31 07:50:13 +07:00
ragilap 709e304f7f feat(BE-281): adjustment bug erorr 500 if 404 record projectflock 2025-12-31 07:39:20 +07:00
ragilap d994cfdce7 productstock 2025-12-31 07:08:03 +07:00
Hafizh A. Y. d3bb00a06a Merge branch 'fix/nonstock-undefined-field' into 'development'
fix(be): nonstock response supplier null to empty array

See merge request mbugroup/lti-api!120
2025-12-30 23:57:54 +00:00
Hafizh A. Y 5302713811 fix(be): nonstock response supplier null to empty array 2025-12-31 06:52:38 +07:00
Hafizh A. Y. f698ca070c Merge branch 'fix/nonstock-undefined-field' into 'development'
fix(be): remove omitempty in dto and validation nonstock

See merge request mbugroup/lti-api!118
2025-12-30 23:44:27 +00:00
Hafizh A. Y 6c42119f4d fix(be): remove omitempty in dto and validation nonstock 2025-12-31 06:43:34 +07:00
ragilap bc03c469f2 feat(BE-278): add delete document s3 2025-12-31 04:00:41 +07:00
ragilap fd5f83ca58 feat(BE-278): unrestrict feat warehouse purchase,adding purchase upload document 2025-12-31 03:50:58 +07:00
Hafizh A. Y. 299c8c7177 Merge branch 'fix/setup-seeder' into 'development'
fix: setup seeder for development

See merge request mbugroup/lti-api!117
2025-12-30 16:54:23 +00:00
Hafizh A. Y 78359db880 fix: setup seeder for development 2025-12-30 23:52:37 +07:00
aguhh18 91fd8a253b feat(BE): update foreign key constraints for project_chickins and adjust service logic for project flock kandang retrieval 2025-12-30 20:16:40 +07:00
aguhh18 d91ff7a4c2 feat(BE): add supplier_id filter to GetAll method and update validation for query parameters 2025-12-30 20:03:23 +07:00
aguhh18 3ecea6741f feat(BE): update DeleteOne method to use uint64 for ID and implement soft delete logic 2025-12-30 19:39:10 +07:00
aguhh18 b988f45a0b feat(BE): update expense DTO and service to directly use location from expense 2025-12-30 19:30:42 +07:00
Hafizh A. Y. 10799cc1ed Merge branch 'feat/BE/Sprint-8' into 'development'
[FEAT/BE] Partial Merge #3

See merge request mbugroup/lti-api!116
2025-12-30 12:06:19 +00:00
Hafizh A. Y. c9c581ef30 Merge branch 'sprint-8/gio' into 'feat/BE/Sprint-8'
fix api get all closing; fix get closing sapronak; fix get all maste data product

See merge request mbugroup/lti-api!115
2025-12-30 09:36:48 +00:00
Hafizh A. Y. 6ee795cf2a Merge branch 'dev/teguh' into 'feat/BE/Sprint-8'
feat(BE US#386): add standard_fcr column to production_standard_details and update existing API

See merge request mbugroup/lti-api!114
2025-12-30 09:36:00 +00:00
aguhh18 471fd1dbbf feat(BE): enhance product warehouse handling and automatic calculations for delivery and sales orders 2025-12-30 16:30:44 +07:00
ragilap 4e5caa8cba feat(BE-281): add rbac for uniformity 2025-12-30 15:23:34 +07:00
MacBook Air M1 0285852c42 fix api get all closing; fix get closing sapronak; fix get all maste data product 2025-12-30 14:42:53 +07:00
ragilap 0396aa0255 feat(BE-287):adjustment purchase restrict unfinished 2025-12-30 14:27:50 +07:00
ragilap 756ba223ed feat(BE-281):add standart production into response recording get one 2025-12-30 13:17:01 +07:00
ragilap 0c776e8332 feat(BE-281): uncoment auth 2025-12-30 12:08:49 +07:00
ragilap 90125ffe1a feat(BE-281):add dto standart mean bw and uniformity 2025-12-30 12:07:28 +07:00
aguhh18 c36719cc1a Merge branch 'feat/BE/Sprint-8' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-30 10:42:04 +07:00
aguhh18 e4acd9a21e feat(BE): add standard_fcr column to production_standard_details and update related services and validations 2025-12-30 10:27:12 +07:00
ragilap 9a094b8bfe feat(BE-281):fix document payload 2025-12-30 09:56:48 +07:00
ragilap 16ef73fce3 Merge branch 'feat/BE/Sprint-8' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-281-adjustment_recording 2025-12-30 09:41:42 +07:00
Hafizh A. Y. ddda696454 Merge branch 'fix/BE/US-74-add_production_standart_project_flock' into 'feat/BE/Sprint-8'
feat(BE-74): add production standart to project_flock and implement rbac...

See merge request mbugroup/lti-api!113
2025-12-29 16:22:29 +00:00
ragilap 635049163e feat(BE-74): add production standart to project_flock and implement rbac finance and standart production 2025-12-29 23:15:34 +07:00
Hafizh A. Y. 49af2d6448 Merge branch 'feat/BE/Sprint-8' into 'development'
Feat[BE]: Partial Merge

See merge request mbugroup/lti-api!112
2025-12-29 14:39:58 +00:00
Hafizh A. Y. 68703d8752 Merge branch 'dev/teguh' into 'feat/BE/Sprint-8'
feat(BE): expense(adjust expense add option attach to farm and not to kandang ).

See merge request mbugroup/lti-api!111
2025-12-29 14:39:05 +00:00
Hafizh A. Y. f19a3cb76e Merge branch 'dev/hafizh' into 'feat/BE/Sprint-8'
feat(BE): finance (payment, initial_balance, injection). fix(BE): kandang capacity

See merge request mbugroup/lti-api!110
2025-12-29 14:37:42 +00:00
Hafizh A. Y. d1ba13de76 Merge branch 'feat/BE/Sprint-8' into 'dev/hafizh'
# Conflicts:
#   internal/route/route.go
#   internal/utils/constant.go
2025-12-29 14:37:02 +00:00
ragilap 6523290aaf feat(BE-281): change template excel 2025-12-29 19:44:10 +07:00
ragilap a2066979c1 feat(BE-281): adjustment uniformity for make unique for week,projectflockandang, and date 2025-12-29 19:04:10 +07:00
ragilap 8e7e976946 feat(BE-281): adjustment recording table with handhouse and deleting weight unfinished dto:standart fcr,hand house and others 2025-12-29 16:25:08 +07:00
Hafizh A. Y e30ef5ef10 feat(BE): finance (payment, initial_balance, injection). fix(BE): kandang capacity 2025-12-29 15:48:08 +07:00
aguhh18 bb76d27f25 feat[BE#US386]: add production standards module with CRUD operations
- Created database migration for production standards and related tables.
- Implemented entities for ProductionStandard, ProductionStandardDetail, and StandardGrowthDetail.
- Developed controller for handling production standard requests.
- Added DTOs for data transfer between layers.
- Implemented service layer for business logic related to production standards.
- Created repository interfaces and implementations for data access.
- Added validation for production standard requests.
- Registered routes for production standards in the main application.
2025-12-29 15:47:37 +07:00
aguhh18 dbb13da7c4 Feat[BE]: add migration scripts for product warehouse ID management and create production standards tables with constraints and indexes 2025-12-29 15:47:05 +07:00
aguhh18 ac8536a4a1 Feat[BE]: implement FIFO stock management for marketing delivery products, including migration scripts and updates to related services and DTOs 2025-12-29 15:47:05 +07:00
aguhh18 96c2917834 Feat[BE]: add migration scripts to manage document columns in expenses table for Document service integration 2025-12-29 15:47:05 +07:00
aguhh18 c3302397cc Feat[BE]: integrate document service into expense module and update related DTOs for document handling 2025-12-29 15:47:05 +07:00
aguhh18 c7ae836cf0 Feat[BE]: refactor stock log handling and introduce new log types for adjustments and transfers 2025-12-29 15:47:05 +07:00
aguhh18 20f8a45823 Feat[BE]: update update dto for transfer document 2025-12-29 15:47:05 +07:00
aguhh18 67ddd8e667 Feat[BE]: enhance chickin stock management with FIFO service integration and fix key naming inconsistencies 2025-12-29 15:47:03 +07:00
aguhh18 ebf0f8c5ab Feat[BE]: refactor document handling in transfer service and introduce document type constants 2025-12-29 15:31:57 +07:00
aguhh18 7dc5c9e9a5 Feat[BE]: add document handling to stock transfer process 2025-12-29 15:26:38 +07:00
aguhh18 306cf11fee Feat[BE]: integrate FIFO service for chickin stock management 2025-12-29 15:26:38 +07:00
aguhh18 9ee3b7582c Feat[BE]: on chickin laying covert Pullet to Layer 2025-12-29 15:26:38 +07:00
ragilap 8dfb224614 feat(BE-281): changes std deviasi first 100 data to all 2025-12-29 10:13:29 +07:00
ragilap 411d6fe6a9 feat(BE-281): deleting bw in recording 2025-12-29 09:38:49 +07:00
aguhh18 db4e8232b9 feat(BE): enhance closing service and repository with actual usage cost calculations and egg weight tracking 2025-12-29 08:03:00 +07:00
ragilap 644896edfa feat(BE-281): unfinished uniformity and create project flock triger productwarehouse and add new filtering lookup 2025-12-29 00:21:26 +07:00
aguhh18 d945fcd19c Merge branch 'dev/gio' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-28 19:16:53 +07:00
aguhh18 812db3f79e feat(BE): integrate FIFO service for stock adjustments and transfers
- Added FIFO service integration in the adjustments module to manage stockable and usable items for adjustments.
- Created a new repository for adjustment stocks to handle database operations.
- Enhanced the adjustment service to track stock adjustments using FIFO logic for both increase and decrease operations.
- Updated product warehouse DTOs and repositories to include project flock information.
- Implemented FIFO logic in the transfer module to manage stock transfers between warehouses.
- Added integration tests for FIFO operations in stock transfers, ensuring correct stock consumption and replenishment.
2025-12-28 19:15:41 +07:00
MacBook Air M1 10f42ed9c4 feat[BE-378]:Create API Get All HPP Harian Kandang 2025-12-28 18:41:46 +07:00
aguhh18 a0d2c1c7dd feat[BE]: enhance marketing module by adding ProductWarehouseId to marketing delivery product creation 2025-12-28 10:40:20 +07:00
aguhh18 56811f7c5b feat[BE]: integrate kandang repository into expense bridge for enhanced expense management 2025-12-28 08:57:35 +07:00
aguhh18 647bfbb667 Merge branch 'feat/BE/Sprint-8' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-28 08:20:32 +07:00
aguhh18 ec6da57510 feat[BE]: enhance expense management with location and project flock integration, including updates to migrations, entities, services, and validations 2025-12-28 08:13:50 +07:00
Hafizh A. Y. cdfa77566c Merge branch 'dev/teguh' into 'feat/BE/Sprint-8'
Feat[BE US# master data]: create standard production master data and adjust fifo stock module and document module on some main module

See merge request mbugroup/lti-api!109
2025-12-27 07:40:55 +00:00
Hafizh A. Y 1c875a916b feat(BE): finance (payment, initial_balance, injection). fix(BE): kandang capacity 2025-12-27 14:30:03 +07:00
aguhh18 85dc0ecd13 Merge branch 'feat/BE/Sprint-8' of https://gitlab.com/mbugroup/lti-api into HEAD 2025-12-27 11:59:10 +07:00
aguhh18 c9633d1308 feat[BE#US386]: add production standards module with CRUD operations
- Created database migration for production standards and related tables.
- Implemented entities for ProductionStandard, ProductionStandardDetail, and StandardGrowthDetail.
- Developed controller for handling production standard requests.
- Added DTOs for data transfer between layers.
- Implemented service layer for business logic related to production standards.
- Created repository interfaces and implementations for data access.
- Added validation for production standard requests.
- Registered routes for production standards in the main application.
2025-12-27 09:02:16 +07:00
aguhh18 b156e06cee Feat[BE]: add migration scripts for product warehouse ID management and create production standards tables with constraints and indexes 2025-12-26 23:36:53 +07:00
aguhh18 cd14de4dd2 Feat[BE]: implement FIFO stock management for marketing delivery products, including migration scripts and updates to related services and DTOs 2025-12-26 19:02:50 +07:00
aguhh18 54487b0fcf Feat[BE]: add migration scripts to manage document columns in expenses table for Document service integration 2025-12-26 11:21:23 +07:00
aguhh18 a9037991ef Feat[BE]: integrate document service into expense module and update related DTOs for document handling 2025-12-26 11:20:57 +07:00
aguhh18 12e5706318 Feat[BE]: refactor stock log handling and introduce new log types for adjustments and transfers 2025-12-26 09:19:39 +07:00
aguhh18 3e575d96a7 Feat[BE]: update update dto for transfer document 2025-12-24 10:42:27 +07:00
Adnan Zahir 98a34a1640 Merge branch 'feat/BE/Sprint-7' into 'development'
[FEAT/BE][Sprint #7] Reporting, Report Closing, and Adjustment

See merge request mbugroup/lti-api!107
2025-12-24 10:08:35 +07:00
aguhh18 c643e66282 Merge branch 'feat/BE/Sprint-7' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-24 09:25:14 +07:00
aguhh18 9c3d0a44a6 Feat[BE]: enhance chickin stock management with FIFO service integration and fix key naming inconsistencies 2025-12-24 09:24:32 +07:00
aguhh18 e935843cba Feat[BE]: refactor document handling in transfer service and introduce document type constants 2025-12-23 17:51:42 +07:00
Hafizh A. Y. e33b23a2aa Merge branch 'feat/BE/US-304/permission-middleware-adjustment' into 'feat/BE/Sprint-7'
[FIX/BE][US#304]: add refresh token and adjustment permission

See merge request mbugroup/lti-api!106
2025-12-23 07:50:11 +00:00
aguhh18 c55fdb75a7 Feat[BE]: add document handling to stock transfer process 2025-12-23 14:10:08 +07:00
ragilap 3a27917afc Merge branch 'feat/BE/Sprint-7' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-304/permission-middleware-adjustment 2025-12-23 14:07:41 +07:00
Hafizh A. Y. c0132e5880 Merge branch 'dev/gio' into 'feat/BE/Sprint-7'
rename api closing data produksi

See merge request mbugroup/lti-api!105
2025-12-23 06:55:15 +00:00
aguhh18 3d13cd966a Feat[BE]: integrate FIFO service for chickin stock management 2025-12-23 12:26:35 +07:00
ragilap b41bb79125 Fix(BE-304):uncomment auth 2025-12-23 11:50:45 +07:00
ragilap a2b8ebe665 Fix(BE-278):fixing total price in purchase 2025-12-23 11:50:00 +07:00
ragilap 2d8f20b70e Fix(BE-304):add refresh token and adjustment permission 2025-12-23 08:57:41 +07:00
MacBook Air M1 824eb5905f resolve conflict to sprint 7 2025-12-22 15:22:12 +07:00
MacBook Air M1 817b6f82d0 rename api closing data produksi 2025-12-22 15:15:42 +07:00
aguhh18 cbd3047a17 Feat[BE]: on chickin laying covert Pullet to Layer 2025-12-22 13:51:27 +07:00
Hafizh A. Y. ff4b4afcca Merge branch 'feat/BE/US-304/permission-middleware-adjustment' into 'feat/BE/Sprint-7'
[FEAT/BE][US#304]: permission middleware adjustment

See merge request mbugroup/lti-api!104
2025-12-22 03:21:37 +00:00
Hafizh A. Y. 240cd72204 Merge branch 'dev/gio' into 'feat/BE/Sprint-7'
adjust age closing data produksi

See merge request mbugroup/lti-api!103
2025-12-22 03:20:17 +00:00
Hafizh A. Y. eae69a08fc Merge branch 'dev/teguh' into 'feat/BE/Sprint-7'
[FEAT/BE][US#333,336,338,340]: complete get closing penjualan, get closing keuangan, repport expense, and repport marketing

See merge request mbugroup/lti-api!101
2025-12-22 03:19:38 +00:00
ragilap 17be6abc49 Merge branch 'feat/BE/Sprint-7' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-304/permission-middleware-adjustment 2025-12-22 10:04:25 +07:00
ragilap ef117e66d1 add permission deliveryorder and sales order 2025-12-22 10:03:32 +07:00
aguhh18 4dfb988994 Merge branch 'feat/BE/Sprint-7' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-22 08:27:40 +07:00
MacBook Air M1 dc726c49cf adjust age closing data produksi 2025-12-21 13:03:32 +07:00
Hafizh A. Y. a82df468d2 Merge branch 'feat/BE/US-304/permission-middleware-adjustment' into 'feat/BE/Sprint-7'
[FEAT/BE][US#304/TASK-307,306]: adjustment middleware check if user have permission,create all permission in modules lti

See merge request mbugroup/lti-api!102
2025-12-19 10:27:25 +00:00
ragilap 1af8f0a726 Feat(BE-304): add permission in report and closing 2025-12-19 15:55:30 +07:00
ragilap 63068b8c3e Merge branch 'feat/BE/Sprint-7' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-304/permission-middleware-adjustment 2025-12-19 14:56:33 +07:00
Hafizh A. Y. 5461c8b0ce Merge branch 'feat/BE/US-334-Report-closing-hpp-expedisi' into 'feat/BE/Sprint-7'
[FEAT/BE][US#334] report closing hpp expedisi

See merge request mbugroup/lti-api!100
2025-12-19 07:51:03 +00:00
ragilap 5dc5f4c589 Merge branch 'feat/BE/Sprint-7' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-334-Report-closing-hpp-expedisi 2025-12-19 14:43:45 +07:00
ragilap ab9c7c216a Feat(BE-304): add permission in report and closing 2025-12-19 14:37:54 +07:00
Hafizh A. Y. faa0861451 Merge branch 'dev/gio' into 'feat/BE/Sprint-7'
feat[BE-375]: add api get one closing data produksi

See merge request mbugroup/lti-api!99
2025-12-19 07:23:44 +00:00
Hafizh A. Y. 2eade07f0a Merge branch 'feat/BE/US-339-reporting-pembelian-per-supplier' into 'feat/BE/Sprint-7'
Feat/be/us 339 reporting pembelian per supplier

See merge request mbugroup/lti-api!98
2025-12-19 07:22:21 +00:00
ragilap dbb9db960f Merge branch 'feat/BE/Sprint-7' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-304/permission-middleware-adjustment 2025-12-19 14:19:40 +07:00
aguhh18 fa6d82b79a feat[BE-384]: enhance closing reports by introducing calculation context and improving data handling; refactor related functions for better clarity and maintainability 2025-12-19 08:30:05 +07:00
MacBook Air M1 207382b3b0 fix get all inventory product stock 2025-12-19 07:05:11 +07:00
aguhh18 e551995c66 feat[BE-384]: enhance reporting by adding chickin quantity and egg production weight calculations; refactor HPP calculations to consider product categories 2025-12-18 17:56:18 +07:00
ragilap cb076d92ac Feat(BE-339):Fixing dto reporting per supplier, and adjust limit 2025-12-18 16:41:56 +07:00
ragilap f5c80fa560 Feat(BE-339):Fixing dto reporting per supplier 2025-12-18 16:21:46 +07:00
ragilap 14a4d9e944 Feat(BE-334):Fixing dto closing hpp expedisi 2025-12-18 16:02:57 +07:00
MacBook Air M1 84da0c27e0 merge sprint 7 and resolve conflict 2025-12-18 15:33:06 +07:00
MacBook Air M1 047162699e adjust response api closing data produksi 2025-12-18 15:25:15 +07:00
aguhh18 c95f90f0b9 Refactor[BE]: refactor expense category handling to use constants for BOP and NON-BOP 2025-12-18 15:03:37 +07:00
aguhh18 9e0b4be4dd feat[BE]: add flags to product seeds for better categorization 2025-12-18 14:52:51 +07:00
aguhh18 f2df7f4847 feat[BE]: add overhead and ekspedisi items to profit loss report; include total depletion in closing report calculation 2025-12-18 14:49:48 +07:00
kris 30231fabe9 Edit Dockerfile 2025-12-18 06:51:52 +00:00
kris e738a97e4c Delete docker-compose.yaml 2025-12-18 06:50:41 +00:00
kris 81f4a5e33e Delete docker-compose.local.yml 2025-12-18 06:50:22 +00:00
kris 1e9fdd2b0d Update .gitlab-ci.yml file 2025-12-18 06:41:04 +00:00
MacBook Air M1 d675b1e826 feat[BE-375]: get api closing data produksi 2025-12-18 13:32:48 +07:00
ragilap e52a02b1c0 Feat(BE-339): make reporting purchase per supplier with filterization 2025-12-18 11:30:55 +07:00
aguhh18 096a446450 feat[BE]: update HPP calculations to use totalWeightProduced and totalActualPopulation 2025-12-18 10:45:04 +07:00
aguhh18 1b23861656 feat[BE]: membetulkan perhitungan hpp di module penjualan harian 2025-12-18 09:58:31 +07:00
aguhh18 a7069a2e50 Merge branch 'dev/gio' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-18 09:41:51 +07:00
ragilap 3bfc401206 Feat(BE-334): make reporting closing hpp for project_flock_kandang 2025-12-17 13:56:51 +07:00
MacBook Air M1 21d22c20a3 add constant flag 2025-12-17 13:20:00 +07:00
aguhh18 d9a1372077 feat[BE]:: add totalHppPricePerKg to marketing report summary 2025-12-17 11:34:08 +07:00
aguhh18 40f192660d Feat[BE]:: adjust marketing report API 2025-12-17 11:30:49 +07:00
aguhh18 afe4b2ffe3 feat[BE}: change get penjualan repport dto an add more params 2025-12-16 21:10:48 +07:00
ragilap eef254021c Merge branch 'feat/BE/Sprint-7' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-334-Report-closing-hpp-expedisi 2025-12-16 14:49:53 +07:00
ragilap cd739f41b9 Feat(BE-339): make a report for purchasing supplier 2025-12-16 14:42:31 +07:00
Adnan Zahir 8f77031e02 Merge branch 'feat/BE/Sprint-6' into 'development'
[FEAT & FIX/BE] Closing Perhitungan Sapronak, Approval unclose issue, allocation issue, marketing report, lookup issue, etc.

See merge request mbugroup/lti-api!97
2025-12-16 14:05:41 +07:00
Hafizh A. Y. 062a7937e2 Merge branch 'feat/BE/US-284/Report-counting-sapronak' into 'feat/BE/Sprint-6'
Feat/be/us 284/report counting sapronak

See merge request mbugroup/lti-api!94
2025-12-16 04:15:43 +00:00
Hafizh A. Y. 4094d38d7b Merge branch 'dev/teguh' into 'feat/BE/Sprint-6'
[FIX/BE]: fixing stock adjustmetn get all, lookup project flock, and preload in project flock kandangs

See merge request mbugroup/lti-api!95
2025-12-16 04:14:32 +00:00
ragilap cf7b3418a5 fixing report-counting-sapronak 2025-12-16 10:44:19 +07:00
aguhh18 d5bc6838c8 FEAT[BE]: create marketing report API 2025-12-15 16:17:37 +07:00
aguhh18 efaeb89ca1 Fix[BE]: fix typo penamaan route 2025-12-15 13:39:02 +07:00
GitLab Deploy Bot b6a60d5009 remove 2025-12-15 09:25:50 +07:00
aguhh18 a0a143b8ac FEAT[BE} : adjust wrong response on get repport Expense 2025-12-15 09:18:26 +07:00
aguhh18 cbb3368141 FEAT[BE]: implement expense report retrieval with filtering options 2025-12-15 09:11:26 +07:00
ragilap fc49cef781 add counting hpp-expedition by project 2025-12-14 23:15:30 +07:00
aguhh18 c79e35c217 FIX[BE} fixing get all adjustment change respose json 2025-12-11 12:34:13 +07:00
ragilap f60564d673 fix projectflock approval with dto 2025-12-11 11:27:50 +07:00
kris b8425c0f58 Edit .air.toml 2025-12-11 04:06:51 +00:00
aguhh18 0de2021308 FIX[BE] : fix project flock kandang get all API 2025-12-11 09:42:32 +07:00
ragilap 3ada837b8b feat/BE/US-284/TASK-289-Create API (GET ONE in tab Perhitungan Sapronak),fix approval unclose issue,fix stock allocation issue 2025-12-11 09:38:20 +07:00
aguhh18 c062d838e0 Fix[BE]: fix 500 API Loookup project flock 2025-12-11 09:26:24 +07:00
ragilap 4ce7611c26 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-284/Report-counting-sapronak 2025-12-11 09:05:20 +07:00
Adnan Zahir 2dd3e3e271 Merge branch 'feat/BE/Sprint-6' into 'development'
add approval projectflockkandang closed,expense must be done,stock must empty by flag  unfinished:need info approval fix

See merge request mbugroup/lti-api!91
2025-12-10 22:24:02 +07:00
Hafizh A. Y. e98d0a9fa1 Merge branch 'feat/BE/US-279/closing-produksi' into 'feat/BE/Sprint-6'
Feat/be/us 279/closing produksi

See merge request mbugroup/lti-api!93
2025-12-10 14:39:00 +00:00
ragilap 08c8c4a747 fix purchase due date and dto 2025-12-10 21:11:33 +07:00
ragilap de6304332b fix purchase due date 2025-12-10 21:10:46 +07:00
Hafizh A. Y. f073bcc2c1 Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
fix migration down product warehouses

See merge request mbugroup/lti-api!92
2025-12-10 14:07:11 +00:00
giovanni-ce 4853891191 fix migration down product warehouses 2025-12-10 18:12:31 +07:00
Hafizh A. Y. 086184bbaa Merge branch 'feat/BE/US-279/closing-produksi' into 'feat/BE/Sprint-6'
[FEAT/BE][US#279,278]-restriction expense not finish and stock not used,add status project flock completed, fix dto purchase, fix dto nonstock supplier, purchase

See merge request mbugroup/lti-api!90
2025-12-10 10:13:32 +00:00
ragilap 4161dcfbdd change project flock change stepclosed to selesai 2025-12-10 17:13:05 +07:00
ragilap d0309f25dd uncomment auth 2025-12-10 17:09:01 +07:00
ragilap 59ebe29ec8 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi 2025-12-10 17:03:40 +07:00
ragilap 2b6ba3a41d feat/BE/US-304/TASK-292,293-restriction expense not finish and stock not used,add status project flock completed, fix dto purchase, fix dto nonstock supplier, purchase 2025-12-10 16:30:17 +07:00
Adnan Zahir bb1e6833f0 Merge branch 'feat/BE/Sprint-6' into 'development'
Feat[BE-297]: Create sub module inventory product stock; create api list product stock and api get one product stock

See merge request mbugroup/lti-api!89
2025-12-10 15:21:59 +07:00
Hafizh A. Y. a536094481 Merge branch 'dev/teguh' into 'feat/BE/Sprint-6'
[FEAT/BE][US#333, 338] : creating getone overhead,  inisiating repport API and fixing some bugs on chikin

See merge request mbugroup/lti-api!87
2025-12-10 08:14:07 +00:00
aguhh18 c33cc05f72 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-10 13:47:30 +07:00
aguhh18 3f9865d267 feat[BE]: menambahkan repo expense dan menhapus API API yang tidak akan digunakan di module repport 2025-12-10 13:41:53 +07:00
Hafizh A. Y. 822ca0268e Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
feat[BE-332]: add api get one closing tab sapronak; adjust response get one general information; resolve conflict to sprint 6

See merge request mbugroup/lti-api!86
2025-12-10 06:36:48 +00:00
aguhh18 16d1358b3a FIX[BE}: really fixed duplicate SO number 2025-12-10 11:53:21 +07:00
aguhh18 e00f168a15 Fix[BE} : Fixing duplocate SO number 2025-12-10 11:31:49 +07:00
giovanni-ce 79d488c979 adjust create product warehouse at adjustment and transfer 2025-12-10 11:22:12 +07:00
ragilap 2effa08648 feat/BE/US-304/TASK-307,306-adjustment middleware check if user have permission,create all permission in modules lti 2025-12-10 08:53:09 +07:00
aguhh18 576f8083a3 Feat[BE}: inisiate repport module 2025-12-10 08:23:52 +07:00
aguhh18 d7c543bc9d Refactor[BE]: : delete sales orders and delivery order folder and refactor to just one root marketing folder 2025-12-09 19:24:17 +07:00
giovanni-ce 4a2a80916f adjust response api get all closing, response api get closing tab sapronak 2025-12-09 16:23:05 +07:00
aguhh18 511e5501bb feat[BE]: create GetOverhead API, and fixing chickin use newest productwarehouse schema 2025-12-09 15:32:11 +07:00
ragilap 0fbf04fc1d add restrict for expense,purchase,adjustment transfer: unfinished 2025-12-09 15:16:01 +07:00
giovanni-ce 536e76d481 feat[BE-298]: add api get all list closing 2025-12-09 09:19:50 +07:00
giovanni-ce 29aa737422 resolve conlict to sprint 6 2025-12-08 22:14:55 +07:00
giovanni-ce 26f2f3ccbf adjust response get one general information closing 2025-12-08 22:02:02 +07:00
giovanni-ce 4b147a3be7 feat[BE-332]: add api get one tab sapronak 2025-12-08 21:33:29 +07:00
ragilap 7094d90034 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi 2025-12-08 17:31:06 +07:00
ragilap e6094528b5 add project flock middleware 2025-12-08 17:30:11 +07:00
ragilap 347f21b45c uncomment auth 2025-12-08 14:19:34 +07:00
Hafizh A. Y. 89b23b0653 Merge branch 'fix/BE/US-282/adjustment-recording-egg' into 'feat/BE/Sprint-6'
fix/BE/US-282/TASK-301,302,303-Adjust Schema Database, Adjust Validation and Req Body, and fixing daily gain, and change logic daily gain

See merge request mbugroup/lti-api!85
2025-12-08 07:15:59 +00:00
Hafizh A. Y. e2a6c2a733 Merge branch 'feat/BE/US-278/Purchase-Expedition-bop' into 'feat/BE/Sprint-6'
feat/BE/US-278/TASK-288,289-adjust schema database,Create trigger in expense...

See merge request mbugroup/lti-api!84
2025-12-08 07:14:54 +00:00
ragilap e0e2d91db5 feat/BE/US-284/TASK-,299-Create API (GET ONE in tab Perhitungan Sapronak),add filtering by flag 2025-12-08 13:40:37 +07:00
ragilap 6e176688fa feat/BE/US-282/TASK-301,302,303-Adjust Schema Database, Adjust Validation and Req Body, and fixing daily gain, and change logic daily gain 2025-12-08 12:49:50 +07:00
ragilap cbb7f45c5f Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into fix/BE/US-282/adjustment-recording-egg 2025-12-08 12:43:38 +07:00
ragilap fc9197d00a feat/BE/US-278/TASK-288,289-adjust schema database,Create trigger in expense module, add filter in warehouse linked to project flock, and implement fifo system 2025-12-08 12:12:21 +07:00
ragilap f8e0614d50 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-278/Purchase-Expedition-bop 2025-12-08 11:55:26 +07:00
ragilap a8434a5246 feat/BE/US-284/TASK-,299-Create API (GET ONE in tab Perhitungan Sapronak) 2025-12-08 11:28:32 +07:00
Hafizh A. Y. 9f239b1840 Merge branch 'dev/teguh' into 'feat/BE/Sprint-6'
[FEAT/BE][US#277, 280/TASK#290,291,294,295] : Adjust Schema Database table expense and adjust expense API, and finishing module project budgets

See merge request mbugroup/lti-api!80
2025-12-08 02:47:50 +00:00
Hafizh A. Y. 167fd6d6cb Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
fix query changes field stock logs

See merge request mbugroup/lti-api!83
2025-12-08 02:46:50 +00:00
aguhh18 ec2aca936c Merge branch sprint 6 into dev/teguh 2025-12-08 09:20:54 +07:00
aguhh18 f701b30cb3 Merge branch 'feat/BE/Sprint-6' into 'dev/teguh' - merge all closing methods 2025-12-08 08:39:17 +07:00
ragilap 0a18753dde feat/BE/US-278/TASK-288,289-adjust schema database,Create trigger in expense module, add filter in warehouse linked to project flock 2025-12-08 01:23:21 +07:00
ragilap 4638fba318 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-278/Purchase-Expedition-bop 2025-12-08 01:10:37 +07:00
giovanni-ce 296e8e4c18 fix query changes field stock logs 2025-12-06 22:29:50 +07:00
ragilap a586fe3781 update purchase triger to expense 2025-12-06 21:09:23 +07:00
ragilap 2d3f7f7ef9 update purchase triger to expense 2025-12-06 21:06:53 +07:00
Hafizh A. Y. 67f7ec3a40 Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
feat[BE-298]: add api get one closing general information

See merge request mbugroup/lti-api!82
2025-12-06 04:34:31 +00:00
ragilap 2fbf66f9f7 add function for read closing project flock kandang and project flock 2025-12-05 22:51:59 +07:00
ragilap 70b2a5a2d1 deleted grade in recording egg unfinished: daily gain question, and confirm counting about fcr, adg, mortality and others 2025-12-05 21:58:51 +07:00
aguhh18 008709c19c Feat[BE-300]: add preload for kandang for get penjualan 2025-12-05 19:08:58 +07:00
ragilap 6572176cca feat/BE/US-33/TASK-292,293,Adjust Project Flock status (add status Selesai), Validate with restriction when expense not finish and stock is not used 2025-12-05 17:47:03 +07:00
giovanni-ce 4c63bd14c3 feat[BE-298]: add api get one closing general information 2025-12-05 17:15:05 +07:00
ragilap c593df661c Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi 2025-12-05 14:09:37 +07:00
ragilap ee2db748ea implement bop for expedition must recheck and qty in staff purchase need info 2025-12-05 14:08:54 +07:00
aguhh18 5afee298b0 FIX[BE]: uncomment middleware usage for delivery and sales orders routes 2025-12-05 13:44:57 +07:00
aguhh18 2bc67a8433 FIX[BE] : fixing deleted create at and create by on product warehouse 2025-12-05 13:35:12 +07:00
aguhh18 b4ccd33ea0 FIX{BE]: fixing product warehouse delete created user on preload 2025-12-05 13:30:36 +07:00
aguhh18 c279303b99 Feat[BE-300]: creating API Get closing penjualan 2025-12-05 12:31:52 +07:00
aguhh18 8c883669d3 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-04 20:11:17 +07:00
ragilap fc14f9a98f uncoment auth middleware 2025-12-04 18:58:20 +07:00
ragilap 17269d701c adjustment create project flock must have a relation for location,area and kandang 2025-12-04 18:54:04 +07:00
ragilap c3305d3089 uncomment auth middleware 2025-12-04 18:45:45 +07:00
ragilap b43e2b44ec deleted edit function in project-flock, and must retest closing feat after fixing product warehouse 2025-12-04 18:44:56 +07:00
ragilap 6e3a8f3551 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi 2025-12-04 18:18:53 +07:00
ragilap 415d5c0e67 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi 2025-12-04 15:03:22 +07:00
ragilap 1bca29cd31 adjustment recording adding weight in recording egg : need info, deleted grading egg, adjustment validation if must be changed again 2025-12-04 14:55:42 +07:00
ragilap ea294c6a18 add approval projectflockkandang closed,expense must be done,stock must empty by flag unfinished:need info approval fix 2025-12-03 21:46:02 +07:00
ragilap d572d04e3b Merge branch 'dev/ragil' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi 2025-12-03 19:56:00 +07:00
aguhh18 beee88322a FIX[BE] : fixing wrong project flock validation 2025-12-03 14:23:32 +07:00
aguhh18 1b464884c5 Feat[BE-290]: enhance expense update functionality and validation 2025-12-03 12:02:58 +07:00
aguhh18 31699f4162 FIX[BE]: fixing nonstock sometimes isn't appeared on get one 2025-12-03 11:14:26 +07:00
aguhh18 e667d88218 Feat[BE]: create resubmit projectflock API 2025-12-02 12:39:58 +07:00
aguhh18 002981e63b Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-02 09:35:02 +07:00
aguhh18 1d0ef8fb93 Feat[BE#280]:add project budgets to body create API and get one API 2025-12-02 09:32:42 +07:00
ragilap d76db26a4d feat/BE/US-279/Closing unfinished 2025-12-01 16:49:13 +07:00
aguhh18 29f0fd6edb Merge branch 'dev/ragil' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-11-28 15:25:52 +07:00
767 changed files with 121619 additions and 8198 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -3,7 +3,7 @@ root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ./cmd/api"
cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api"
bin = "tmp/main"
full_bin = "APP_ENV=dev ./tmp/main"
include_ext = ["go", "tpl", "tmpl", "html"]
+5 -1
View File
@@ -9,11 +9,13 @@ main
bin/
*.exe
*.out
.air.toml
Makefile
docker-compose.local.yml
docker-compose.yaml
Dockerfile
Dockerfile.local
.gitlab-ci.yml
# Go build cache
.gocache/
vendor
@@ -27,3 +29,5 @@ coverage/
.vscode/
.idea/
*.swp
.DS_Store
.gemini/
+188 -78
View File
@@ -1,90 +1,200 @@
stages:
- deploy
- build
- gitops
deploy-dev:
stage: deploy
image: alpine:3.20
variables:
AWS_REGION: ap-southeast-3
ECR_REGISTRY: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com
ECR_REPO_NAME: mbugroup/lti-api
ECR_REPOSITORY: ${ECR_REGISTRY}/${ECR_REPO_NAME}
TARGET_PLATFORM: linux/amd64
DOCKER_HOST: unix:///var/run/docker.sock
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
workflow:
rules:
# run untuk branch utama & MR
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- when: never
# =========================
# Helper: login ECR
# =========================
.ecr_login: &ecr_login |
AWS_CLI_ENV_ARGS=""
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
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 \
ecr get-login-password --region "$AWS_REGION" || true)"
if [ -z "$PASS" ]; then
echo "ERROR: Failed to get ECR login password."
exit 1
fi
echo "$PASS" | docker login --username AWS --password-stdin "$ECR_REGISTRY"
# =========================
# MR
# =========================
build_mr:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
variables:
DEPLOY_APP: "LTI-MBUGROUP"
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
before_script:
- echo "🧰 Installing dependencies..."
- apk update && apk add --no-cache openssh git curl bash
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
echo "Build (MR) : $ECR_REPOSITORY:$IMAGE_TAG"
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
echo "Pushing image for MR..."
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
# Setup SSH di runner
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa
# =========================
# DEVELOPMENT (push branch development)
# =========================
build_push_dev:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
echo "Build & push (dev): $ECR_REPOSITORY:$IMAGE_TAG"
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
update_gitops_dev_lti:
stage: gitops
image: public.ecr.aws/docker/library/alpine:3.20
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"'
needs: ["build_push_dev"]
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
GITOPS_BRANCH: main
VALUES_FILE: environments/lti/dev/lti-values-dev.yaml
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
before_script:
- set -eu
- apk add --no-cache git yq
- git config --global user.email "ci@gitlab"
- git config --global user.name "gitlab-ci"
script: |
set -eu
rm -rf gitops
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
cd gitops
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
echo "Updating dev image.tag to $IMAGE_TAG"
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
git add "$VALUES_FILE"
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "lti dev deploy ${IMAGE_TAG}"
git push origin "$GITOPS_BRANCH"
cd /home/devops/docker/deployment/development/lti-api
# =========================
# PRODUCTION (push branch production)
# =========================
build_push_prod:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
variables:
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
# force base image pulls via AWS ECR Public to avoid Docker Hub TLS timeout
sed -i 's|^FROM golang:1.23-alpine AS builder$|FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder|' Dockerfile
sed -i 's|^FROM alpine:3.20$|FROM public.ecr.aws/docker/library/alpine:3.20|' Dockerfile
echo "Build & push (prod): $ECR_REPOSITORY:$IMAGE_TAG"
docker build --platform "$TARGET_PLATFORM" -f Dockerfile -t "$ECR_REPOSITORY:$IMAGE_TAG" .
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
update_gitops_prod_lti:
stage: gitops
image: public.ecr.aws/docker/library/alpine:3.20
tags: [self-hosted-dev]
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
needs: ["build_push_prod"]
variables:
IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
GITOPS_BRANCH: main
VALUES_FILE: environments/lti/prod/lti-values-prod.yaml
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
before_script:
- set -eu
- apk add --no-cache git yq
- git config --global user.email "ci@gitlab"
- git config --global user.name "gitlab-ci"
script: |
set -eu
rm -rf gitops
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
cd gitops
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
echo "Updating prod image.tag to $IMAGE_TAG"
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
# Fetch/reset pakai SSH
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
git reset --hard origin/development
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
"; then
STATUS='success';
else
STATUS='failed';
fi;
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
if [ "$STATUS" = "success" ]; then
COLOR=3066993;
TITLE="✅ Deployment API Succeeded";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully.";
else
COLOR=15158332;
TITLE="❌ Deployment API Failed Gaes";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed.";
fi;
echo "{
\"username\": \"CI Bot\",
\"embeds\": [{
\"title\": \"$TITLE\",
\"description\": \"$DESC\",
\"color\": $COLOR,
\"fields\": [
{\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true},
{\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true},
{\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false},
{\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false}
]
}]
}" > payload.json;
echo "📡 Sending notification to Discord...";
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
only:
- development
environment:
name: development
git add "$VALUES_FILE"
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "lti prod deploy ${IMAGE_TAG}"
git push origin "$GITOPS_BRANCH"
+34 -11
View File
@@ -1,20 +1,43 @@
FROM golang:1.23-alpine
# =========================
# Builder stage
# =========================
FROM public.ecr.aws/docker/library/golang:1.23-alpine AS builder
# Install dependensi dasar
RUN apk add --no-cache git curl bash build-base
RUN apk add --no-cache git ca-certificates tzdata
WORKDIR /app
# Install Air (pakai repo baru air-verse)
RUN go install github.com/air-verse/air@v1.52.3
WORKDIR /lti-api
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build API binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api
# Build SEED binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-seed ./cmd/seed
# Build migrate CLI with postgres + file drivers
RUN GOBIN=/usr/local/bin go install -tags "postgres file" -ldflags="-s -w" github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.3
# =========================
# Runtime stage
# =========================
FROM public.ecr.aws/docker/library/alpine:3.20
RUN apk add --no-cache ca-certificates tzdata curl bash postgresql-client \
&& adduser -D -H -u 10001 appuser
WORKDIR /app
COPY --from=builder /app/lti-api /app/lti-api
COPY --from=builder /app/lti-seed /app/lti-seed
COPY --from=builder /usr/local/bin/migrate /app/migrate
COPY --from=builder /app/internal/database/migrations /app/migrations
USER appuser
EXPOSE 8081
CMD ["air", "-c", ".air.toml"]
CMD ["/app/lti-api"]
+2 -1
View File
@@ -110,4 +110,5 @@ IT Development PT Mitra Berlian Unggas Group
## 📃 License
This project is private. All rights reserved.
> This project is private. All rights reserved.
# mr test Sat 7 Feb 2026 00:14:58 WIB
BIN
View File
Binary file not shown.
+159
View File
@@ -0,0 +1,159 @@
stages:
- build
- gitops
variables:
AWS_REGION: ap-southeast-3
ECR_REGISTRY: 886436954922.dkr.ecr.ap-southeast-3.amazonaws.com
ECR_REPO_NAME: mbugroup/lti-api
ECR_REPOSITORY: ${ECR_REGISTRY}/${ECR_REPO_NAME}
DOCKER_HOST: unix:///var/run/docker.sock
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
workflow:
rules:
- if: '$CI_COMMIT_BRANCH'
# =========================
# Helper: login ECR
# =========================
.ecr_login: &ecr_login |
AWS_CLI_ENV_ARGS=""
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}"
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
fi
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
ecr get-login-password --region "$AWS_REGION" || true)"
if [ -z "$PASS" ]; then
echo "ERROR: Failed to get ECR login password."
exit 1
fi
echo "$PASS" | docker login --username AWS --password-stdin "$ECR_REGISTRY"
# =========================
# DEV
# =========================
build_push_dev_lti:
stage: build
image: public.ecr.aws/docker/library/docker:27
tags: [self-hosted-dev]
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
before_script:
- set -eu
- docker version
- docker info
- *ecr_login
script: |
set -eu
echo "Build & push: $ECR_REPOSITORY:$IMAGE_TAG"
docker build \
-t "$ECR_REPOSITORY:$IMAGE_TAG" \
.
docker push "$ECR_REPOSITORY:$IMAGE_TAG"
update_gitops_dev_lti:
stage: gitops
image: public.ecr.aws/docker/library/alpine:3.20
tags: [self-hosted-dev]
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
needs: ["build_push_dev_lti"]
variables:
IMAGE_TAG: "dev-${CI_COMMIT_SHORT_SHA}"
GITOPS_BRANCH: main
VALUES_FILE: environments/lti/dev/lti-values-dev.yaml
GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
before_script:
- set -eu
- apk add --no-cache git yq
- git config --global user.email "ci@gitlab"
- git config --global user.name "gitlab-ci"
script: |
set -eu
rm -rf gitops
git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
cd gitops
echo "Updating DEV image.tag to $IMAGE_TAG in $VALUES_FILE"
yq -i '.image.repository = strenv(ECR_REPOSITORY)' "$VALUES_FILE"
yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
git add "$VALUES_FILE"
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "lti dev deploy ${IMAGE_TAG}"
git push origin "$GITOPS_BRANCH"
# =========================
# PROD
# =========================
# build_push_prod_lti:
# stage: build
# image: public.ecr.aws/docker/library/docker:27
# tags: [self-hosted-dev]
# rules:
# - if: '$CI_COMMIT_BRANCH == "production"'
# variables:
# IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
# before_script:
# - set -eu
# - docker version
# - docker info
# - *ecr_login
# script: |
# set -eu
# echo "Build & push: $ECR_REPOSITORY:$IMAGE_TAG"
# docker build \
# -t "$ECR_REPOSITORY:$IMAGE_TAG" \
# .
# docker push "$ECR_REPOSITORY:$IMAGE_TAG"
# update_gitops_prod_lti:
# stage: gitops
# image: public.ecr.aws/docker/library/alpine:3.20
# tags: [self-hosted-dev]
# rules:
# - if: '$CI_COMMIT_BRANCH == "production"'
# needs: ["build_push_prod_lti"]
# variables:
# IMAGE_TAG: "prod-${CI_COMMIT_SHORT_SHA}"
# GITOPS_BRANCH: main
# VALUES_FILE: environments/lti/prod/lti-values-prod.yaml
# GITOPS_REPO_URL: https://oauth2:${GITOPS_TOKEN}@gitlab.com/cristian.anggita.parjaman/gitops.git
# before_script:
# - set -eu
# - apk add --no-cache git yq
# - git config --global user.email "ci@gitlab"
# - git config --global user.name "gitlab-ci"
# script: |
# set -eu
# rm -rf gitops
# git clone --depth 1 --branch "$GITOPS_BRANCH" "$GITOPS_REPO_URL" gitops
# cd gitops
# echo "Updating PROD image.tag to $IMAGE_TAG in $VALUES_FILE"
# yq -i '.image.repository = strenv(ECR_REPOSITORY)' "$VALUES_FILE"
# yq -i '.image.tag = strenv(IMAGE_TAG)' "$VALUES_FILE"
# git add "$VALUES_FILE"
# if git diff --cached --quiet; then
# echo "No changes to commit"
# exit 0
# fi
# git commit -m "lti prod deploy ${IMAGE_TAG}"
# git push origin "$GITOPS_BRANCH"
+48
View File
@@ -0,0 +1,48 @@
stages:
- notify
notify_discord_on_mr_request_main_dev:
stage: notify
image: alpine:3.20
rules:
# hanya MR yang target ke main atau development
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")'
when: on_success
- when: never
script:
- apk add --no-cache curl jq coreutils
- |
TIME_HUMAN="$(date '+%d/%m/%y, %H.%M')"
TIME_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
TITLE="${CI_MERGE_REQUEST_TITLE}"
IID="!${CI_MERGE_REQUEST_IID}"
USER_LINE="${GITLAB_USER_NAME} (${GITLAB_USER_LOGIN})"
PROJECT_PATH="${CI_PROJECT_PATH}"
USERNAME="${GITLAB_USER_LOGIN}"
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
DESC="$(printf "**%s**\n\n%s opened merge request %s %s\n%s" \
"$USERNAME" "$USER_LINE" "$IID" "$TITLE" "$TIME_HUMAN")"
payload=$(jq -n \
--arg desc "$DESC" \
--arg project "$PROJECT_PATH" \
--arg timeiso "$TIME_ISO" \
--arg mrurl "$MR_URL" \
'{
"username": "Mock-api - Merge Requests",
"embeds": [
{
"description": ($desc + "\n" + $mrurl),
"color": 15105570,
"footer": { "text": $project },
"timestamp": $timeiso
}
]
}')
curl -sS -H "Content-Type: application/json" \
-d "$payload" \
"$DISCORD_WEBHOOK_URL"
+155
View File
@@ -0,0 +1,155 @@
stages:
- build
- migrate
- deploy
- seed
default:
tags:
- self-hosted-prod
variables:
DOCKER_BUILDKIT: "1"
IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest"
DEPLOY_DIR: "/opt/deploy/lti"
COMPOSE_FILE: "docker-compose.yaml"
# =========================
# BUILD (AUTO)
# =========================
build_production:
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
echo "✅ Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f Dockerfile .
echo "✅ Push image: $IMAGE_NAME"
docker push "$IMAGE_NAME"
echo "✅ Tag latest: $IMAGE_LATEST"
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (PRODUCTION)
# =========================
migrate_production:
stage: migrate
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
- job: build_production
artifacts: false
script: |
set -e
echo "✅ Running migrations (production) ..."
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
set -a
. ./.env
set +a
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
# NOTE: pastikan nama servicenya benar untuk production (ini sebelumnya masih stg-*)
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
echo "✅ Running migrations via migrate/migrate container"
set +e
out=$(docker run --rm \
--network "$NETWORK_NAME" \
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
migrate/migrate:v4.15.2 \
-path=/migrations -database "$DATABASE_URL" up 2>&1)
code=$?
set -e
echo "$out"
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
fi
if [ $code -ne 0 ]; then
echo "❌ Migration failed with exit code $code"
exit $code
fi
echo "✅ Migration applied successfully"
# =========================
# DEPLOY (AUTO)
# =========================
deploy_production:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
- job: build_production
artifacts: false
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_production:
stage: seed
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: manual
- when: never
script: |
set -e
cd "$DEPLOY_DIR"
test -f .env || (echo "❌ .env not found" && exit 1)
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
docker compose --env-file .env pull seed
docker compose --env-file .env run --rm seed
+164
View File
@@ -0,0 +1,164 @@
stages:
- build
- migrate
- deploy
- seed
default:
tags:
- self-hosted-stg
variables:
DOCKER_BUILDKIT: "1"
IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest"
DEPLOY_DIR: "/opt/deploy/stg-lti-api"
COMPOSE_FILE: "docker-compose.yaml"
# =========================
# BUILD (AUTO)
# =========================
build_staging:
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
echo "✅ Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f Dockerfile .
echo "✅ Push image: $IMAGE_NAME"
docker push "$IMAGE_NAME"
echo "✅ Tag latest: $IMAGE_LATEST"
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (AUTO)
# =========================
migrate_staging:
stage: migrate
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
needs:
- job: build_staging
artifacts: false
script: |
set -e
echo "✅ Running migrations (staging) ..."
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
set -a
. ./.env
set +a
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Docker network detected: $NETWORK_NAME"
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
echo "✅ Running migrations via migrate/migrate container"
set +e
out=$(docker run --rm \
--network "$NETWORK_NAME" \
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
migrate/migrate:v4.15.2 \
-path=/migrations -database "$DATABASE_URL" up 2>&1)
code=$?
set -e
echo "$out"
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
fi
if [ $code -ne 0 ]; then
echo "❌ Migration failed with exit code $code"
exit $code
fi
echo "✅ Migration applied successfully"
# =========================
# DEPLOY (AUTO)
# =========================
deploy_staging:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
needs:
- job: migrate_staging
artifacts: false
- job: build_staging
artifacts: false
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_staging:
stage: seed
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: manual
- when: never
needs:
- job: deploy_staging
artifacts: false
allow_failure: false
script: |
set -e
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1)
test -f .env || (echo "❌ .env not found" && exit 1)
docker compose -f "$COMPOSE_FILE" pull seed || true
docker compose -f "$COMPOSE_FILE" run --rm seed
@@ -0,0 +1,297 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
const (
levelAllNoFlagProducts = 1
levelProductName = 2
levelProductWarehouse = 3
qtyEpsilon = 1e-6
)
type targetRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
CurrentQty float64 `gorm:"column:current_qty"`
ComputedQty float64 `gorm:"column:computed_qty"`
}
func main() {
var (
level int
productName string
productWarehouseID uint
apply bool
)
flag.IntVar(
&level,
"level",
levelAllNoFlagProducts,
"CLI level: 1=all products without flags, 2=specific product name (with flags), 3=specific product warehouse id",
)
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
productName = strings.TrimSpace(productName)
if err := validateFlags(level, productName, productWarehouseID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
targets, err := loadTargets(ctx, db, level, productName, productWarehouseID)
if err != nil {
log.Fatalf("failed to load target product warehouses: %v", err)
}
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
if productName != "" {
fmt.Printf("Filter product_name: %s\n", productName)
}
if productWarehouseID > 0 {
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
}
fmt.Printf("Targets found: %d\n\n", len(targets))
if len(targets) == 0 {
fmt.Println("No matching product warehouse rows to process")
return
}
for _, row := range targets {
fmt.Printf(
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f computed_qty=%.3f delta=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
row.ComputedQty-row.CurrentQty,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0\n", len(targets))
return
}
updated := 0
skipped := 0
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, row := range targets {
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
fmt.Printf(
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
row.ProductWarehouseID,
row.CurrentQty,
row.ComputedQty,
)
skipped++
continue
}
if err := tx.Table("product_warehouses").
Where("id = ?", row.ProductWarehouseID).
Update("qty", row.ComputedQty).Error; err != nil {
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
}
fmt.Printf(
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
)
updated++
}
return nil
})
if err != nil {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=1\n", len(targets), updated, skipped)
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=0\n", len(targets), updated, skipped)
}
func validateFlags(level int, productName string, productWarehouseID uint) error {
switch level {
case levelAllNoFlagProducts:
if productName != "" {
return errors.New("--product-name cannot be used on level 1")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 1")
}
case levelProductName:
if productName == "" {
return errors.New("--product-name is required on level 2")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 2")
}
case levelProductWarehouse:
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required on level 3")
}
if productName != "" {
return errors.New("--product-name cannot be used on level 3")
}
default:
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
}
return nil
}
func loadTargets(
ctx context.Context,
db *gorm.DB,
level int,
productName string,
productWarehouseID uint,
) ([]targetRow, error) {
switch level {
case levelAllNoFlagProducts:
return loadTargetsLevel1ByProductWithoutFlags(ctx, db)
case levelProductName:
return loadTargetsLevel2ByProductWarehouseWithFlags(ctx, db, productName)
case levelProductWarehouse:
return loadTargetByProductWarehouseID(ctx, db, productWarehouseID)
default:
return nil, fmt.Errorf("unsupported level %d", level)
}
}
func loadTargetsLevel1ByProductWithoutFlags(ctx context.Context, db *gorm.DB) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("p.deleted_at IS NULL").
Where("f.id IS NULL").
Group("pw.id, pw.product_id, p.name, pw.qty").
Order("pw.id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadTargetsLevel2ByProductWarehouseWithFlags(
ctx context.Context,
db *gorm.DB,
productName string,
) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("p.deleted_at IS NULL").
Where(`
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_id = p.id
AND f.flagable_type = ?
)
`, entity.FlagableTypeProduct).
Where("LOWER(p.name) = LOWER(?)", productName).
Group("pw.id, pw.product_id, p.name, pw.qty").
Order("pw.id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadTargetByProductWarehouseID(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.id = ?", productWarehouseID).
Group("pw.id, pw.product_id, p.name, pw.qty").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func levelLabel(level int) string {
switch level {
case levelAllNoFlagProducts:
return "all products without flags (source: purchase_items by product_warehouse_id)"
case levelProductName:
return "specific product name with flags (source: purchase_items by product_warehouse_id)"
case levelProductWarehouse:
return "specific product_warehouse_id (source: purchase_items by product_warehouse_id)"
default:
return "unknown"
}
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
db := database.Connect(config.DBHost, config.DBName)
service := apikeys.NewService(db)
ctx := context.Background()
switch os.Args[1] {
case "create":
fs := flag.NewFlagSet("create", flag.ExitOnError)
name := fs.String("name", "dashboard-read-api", "integration client name")
environment := fs.String("env", config.AppEnv, "environment label")
permissions := fs.String("permissions", "", "comma separated permission codes")
allArea := fs.Bool("all-area", true, "grant all areas")
areaIDs := fs.String("area-ids", "", "comma separated area ids")
allLocation := fs.Bool("all-location", true, "grant all locations")
locationIDs := fs.String("location-ids", "", "comma separated location ids")
fs.Parse(os.Args[2:])
permissionCodes := apikeys.DefaultDashboardPermissions()
if strings.TrimSpace(*permissions) != "" {
permissionCodes = splitCSV(*permissions)
}
issued, err := service.Create(ctx, apikeys.CreateInput{
Name: *name,
Environment: *environment,
PermissionCodes: permissionCodes,
AllArea: *allArea,
AreaIDs: parseUintCSV(*areaIDs),
AllLocation: *allLocation,
LocationIDs: parseUintCSV(*locationIDs),
})
if err != nil {
panic(err)
}
fmt.Printf("name: %s\n", issued.Record.Name)
fmt.Printf("environment: %s\n", issued.Record.Environment)
fmt.Printf("prefix: %s\n", issued.Record.KeyPrefix)
fmt.Printf("status: %s\n", issued.Record.Status)
fmt.Printf("api_key: %s\n", issued.Key)
case "list":
fs := flag.NewFlagSet("list", flag.ExitOnError)
environment := fs.String("env", "", "filter by environment")
fs.Parse(os.Args[2:])
records, err := service.List(ctx, *environment)
if err != nil {
panic(err)
}
for _, record := range records {
fmt.Printf("%s\t%s\t%s\t%s\tareas=%t\tlocations=%t\n",
record.Environment,
record.KeyPrefix,
record.Status,
record.Name,
record.AllArea,
record.AllLocation,
)
}
case "revoke":
fs := flag.NewFlagSet("revoke", flag.ExitOnError)
environment := fs.String("env", config.AppEnv, "environment label")
prefix := fs.String("prefix", "", "key prefix to revoke")
fs.Parse(os.Args[2:])
if err := service.Revoke(ctx, *environment, *prefix); err != nil {
panic(err)
}
fmt.Printf("revoked %s/%s\n", *environment, *prefix)
default:
usage()
os.Exit(1)
}
}
func usage() {
fmt.Println("usage:")
fmt.Println(" go run ./cmd/api-key create [flags]")
fmt.Println(" go run ./cmd/api-key list [flags]")
fmt.Println(" go run ./cmd/api-key revoke -env <environment> -prefix <prefix>")
}
func splitCSV(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func parseUintCSV(raw string) []uint {
parts := splitCSV(raw)
if len(parts) == 0 {
return nil
}
values := make([]uint, 0, len(parts))
for _, part := range parts {
var value uint
if _, err := fmt.Sscanf(part, "%d", &value); err == nil && value > 0 {
values = append(values, value)
}
}
return values
}
+6 -1
View File
@@ -9,13 +9,15 @@ import (
"syscall"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/cache"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
"gitlab.com/mbugroup/lti-api.git/internal/route"
"gitlab.com/mbugroup/lti-api.git/internal/sso"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
@@ -131,6 +133,7 @@ func setupDatabase() *gorm.DB {
}
func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
middleware.SetAPIKeyAuthenticator(apikeys.NewService(db))
// route.Routes(app, db)
// app.Use(utils.NotFoundHandler)
@@ -169,6 +172,8 @@ func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
return c.Status(status).JSON(body)
})
readAPIRoutes := app.Group("/api")
readapi.RegisterRoutes(readAPIRoutes)
route.Routes(app, db)
app.Use(utils.NotFoundHandler)
}
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
+407
View File
@@ -0,0 +1,407 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
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"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type adjustmentRow struct {
ID uint `gorm:"column:id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
FunctionCode string `gorm:"column:function_code"`
TotalQty float64 `gorm:"column:total_qty"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type routeResolution struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
}
func main() {
var (
idsRaw string
apply bool
)
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
flag.BoolVar(&apply, "apply", false, "Apply delete. If false, run as dry-run")
flag.Parse()
ids, err := parseIDs(idsRaw)
if err != nil {
log.Fatalf("invalid --ids: %v", err)
}
if len(ids) == 0 {
log.Fatal("--ids is required")
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
adjustments, err := loadAdjustments(ctx, db, ids)
if err != nil {
log.Fatalf("failed to load adjustments: %v", err)
}
if len(adjustments) == 0 {
log.Fatal("no adjustments found for provided IDs")
}
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].ID < adjustments[j].ID
})
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
success := 0
failed := 0
skipped := 0
for _, adj := range adjustments {
if strings.TrimSpace(adj.FunctionCode) == "" {
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
skipped++
continue
}
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
if err != nil {
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
failed++
continue
}
switch route.Lane {
case "USABLE":
desiredQty := adj.UsageQty + adj.PendingQty
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
desiredQty = adj.StockLogDecrease
}
activeAlloc, err := countActiveUsableAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count usable allocations: %v\n", adj.ID, err)
failed++
continue
}
fmt.Printf(
"PLAN adj=%d lane=USABLE function=%s usage=%.3f pending=%.3f active_alloc=%d action=reflow_to_zero+delete\n",
adj.ID,
route.FunctionCode,
adj.UsageQty,
adj.PendingQty,
activeAlloc,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
AsOf: &adj.CreatedAt,
IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()),
Tx: tx,
}
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
return fmt.Errorf("reflow usable to zero: %w", err)
}
if err := hardDeleteUsableAllocations(ctx, tx, fifo.UsableKeyAdjustmentOut.String(), adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
return err
}
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
success++
case "STOCKABLE":
removeQty := adj.TotalQty
if removeQty <= 0 && adj.StockLogIncrease > 0 {
removeQty = adj.StockLogIncrease
}
activeAlloc, err := countActiveStockableAllocations(ctx, db, fifo.StockableKeyAdjustmentIn.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count stockable allocations: %v\n", adj.ID, err)
failed++
continue
}
if activeAlloc > 0 {
fmt.Printf(
"FAIL adj=%d reason=stockable still allocated active_alloc=%d action=delete blocked\n",
adj.ID,
activeAlloc,
)
failed++
continue
}
fmt.Printf(
"PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reflow_to_zero+delete\n",
adj.ID,
route.FunctionCode,
adj.TotalQty,
removeQty,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).
Table("adjustment_stocks").
Where("id = ?", adj.ID).
Updates(map[string]any{
"total_qty": 0,
"total_used": 0,
}).Error; err != nil {
return fmt.Errorf("set stockable qty to zero: %w", err)
}
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
AsOf: &adj.CreatedAt,
IdempotencyKey: fmt.Sprintf("delete-adjustment-stockable-%d-%d", adj.ID, time.Now().UnixNano()),
Tx: tx,
}
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
return fmt.Errorf("reflow stockable to zero: %w", err)
}
if err := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
return err
}
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
success++
default:
fmt.Printf("SKIP adj=%d reason=unsupported lane=%s\n", adj.ID, route.Lane)
skipped++
}
}
fmt.Println()
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
if failed > 0 {
os.Exit(1)
}
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func parseIDs(raw string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
out := make([]uint, 0, len(parts))
seen := map[uint]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
v, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid id %q", part)
}
if v == 0 {
return nil, fmt.Errorf("id must be > 0: %q", part)
}
id := uint(v)
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
var rows []adjustmentRow
err := db.WithContext(ctx).
Table("adjustment_stocks a").
Select(`
a.id,
a.product_warehouse_id,
pw.product_id,
a.function_code,
COALESCE(a.total_qty, 0) AS total_qty,
COALESCE(a.usage_qty, 0) AS usage_qty,
COALESCE(a.pending_qty, 0) AS pending_qty,
COALESCE((
SELECT sl.increase
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_increase,
COALESCE((
SELECT sl.decrease
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_decrease,
a.created_at
`).
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
Where("a.id IN ?", ids).
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
var rows []routeResolution
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code, rr.lane, rr.function_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.function_code = ?", functionCode).
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, entity.FlagableTypeProduct, productID).
Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
Order("rr.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
}
selected := rows[0]
for _, row := range rows {
if row.Lane != selected.Lane {
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
}
}
selected.FunctionCode = functionCode
return &selected, nil
}
func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
return count, err
}
func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockableType string, stockableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
return count, err
}
func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationPurposeConsume).
Error
}
func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ? AND allocation_purpose = ?", stockableType, stockableID, entity.StockAllocationPurposeConsume).
Error
}
func hardDeleteAdjustmentStockLogs(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_logs WHERE loggable_type = ? AND loggable_id = ?", "ADJUSTMENT", adjustmentID).
Error
}
func hardDeleteAdjustment(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM adjustment_stocks WHERE id = ?", adjustmentID).
Error
}
+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
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"fmt"
"os"
"path/filepath"
"gitlab.com/mbugroup/lti-api.git/internal/cache"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
"gitlab.com/mbugroup/lti-api.git/internal/route"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
func main() {
root, err := findRepoRoot()
if err != nil {
panic(err)
}
readapi.PrimeBuildConfig()
cache.SetRedis(redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}))
app := fiber.New(config.FiberConfig())
app.Get("/healthz", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok", "service": "api", "version": config.Version})
})
app.Get("/readyz", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok", "db": "up", "redis": "up"})
})
route.Routes(app, nil)
artifacts, err := readapi.BuildArtifactsFromApp(app)
if err != nil {
panic(err)
}
files := map[string][]byte{
filepath.Join(root, "docs", "openapi", "read-api.json"): artifacts.OpenAPIJSON,
filepath.Join(root, "docs", "openapi", "read-api.yaml"): artifacts.OpenAPIYAML,
filepath.Join(root, "docs", "postman", "read-api.collection.json"): artifacts.PostmanCollectionJSON,
filepath.Join(root, "docs", "postman", "read-api.environment.json"): artifacts.PostmanEnvironmentJSON,
}
for path, body := range files {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
panic(err)
}
if err := os.WriteFile(path, body, 0o644); err != nil {
panic(err)
}
fmt.Printf("wrote %s\n", path)
}
}
func findRepoRoot() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
current := wd
for {
if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
return "", fmt.Errorf("go.mod not found from %s", wd)
}
current = parent
}
}
+587
View File
@@ -0,0 +1,587 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
type importOptions struct {
FilePath string
Sheet string
Apply bool
}
type headerIndexes struct {
AdjustmentID int
Weight int
}
type adjustmentPriceImportRow struct {
RowNumber int
AdjustmentID uint
Weight float64
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) Error() string {
if i.Row > 0 {
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
}
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
}
type adjustmentResolver interface {
ResolveExistingAdjustmentIDs(ctx context.Context, adjustmentIDs []uint) (map[uint]struct{}, error)
}
type dbAdjustmentResolver struct {
db *gorm.DB
}
type adjustmentPriceStore interface {
UpdatePrice(ctx context.Context, adjustmentID uint, price float64) (bool, error)
}
type txRunner interface {
InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error
}
type dbTxRunner struct {
db *gorm.DB
}
type dbAdjustmentPriceStore struct {
db *gorm.DB
}
type applyRowResult struct {
RowNumber int
AdjustmentID uint
Price float64
Changed bool
}
func main() {
var opts importOptions
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
sheetName, rows, parseIssues, err := parseAdjustmentPriceFile(opts.FilePath, opts.Sheet)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
resolver := dbAdjustmentResolver{db: db}
existingAdjustmentIDs, err := resolver.ResolveExistingAdjustmentIDs(ctx, collectAdjustmentIDs(rows))
if err != nil {
log.Fatalf("failed checking adjustment_id against adjustment_stocks: %v", err)
}
processableRows, skippedRows := splitRowsByExistingIDs(rows, existingAdjustmentIDs)
issues := append([]validationIssue{}, parseIssues...)
sortValidationIssues(issues)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("Sheet: %s\n", sheetName)
fmt.Printf("Rows parsed: %d\n", len(rows))
fmt.Printf("Rows invalid: %d\n", len(issues))
fmt.Printf("Rows processable: %d\n", len(processableRows))
fmt.Printf("Rows skipped_missing: %d\n", len(skippedRows))
fmt.Println()
if len(processableRows) > 0 {
printPlanRows(processableRows)
}
if len(skippedRows) > 0 {
printSkippedRows(skippedRows)
}
if len(processableRows) > 0 || len(skippedRows) > 0 {
fmt.Println()
}
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.Error())
}
fmt.Println()
fmt.Printf(
"Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=%d\n",
len(rows),
len(processableRows),
len(skippedRows),
len(issues),
)
os.Exit(1)
}
if !opts.Apply {
fmt.Printf(
"Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=0\n",
len(rows),
len(processableRows),
len(skippedRows),
)
return
}
results, err := applyIfRequested(ctx, true, dbTxRunner{db: db}, processableRows)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
for _, result := range results {
fmt.Printf(
"DONE row=%d adjustment_id=%d price=%.3f status=%s\n",
result.RowNumber,
result.AdjustmentID,
result.Price,
applyStatus(result.Changed),
)
}
appliedCount := countChangedRows(results)
if len(results) > 0 {
fmt.Println()
}
fmt.Printf(
"Summary: planned=%d processable=%d skipped_missing=%d applied=%d failed=0\n",
len(rows),
len(processableRows),
len(skippedRows),
appliedCount,
)
}
func parseAdjustmentPriceFile(
filePath string,
requestedSheet string,
) (string, []adjustmentPriceImportRow, []validationIssue, error) {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return "", nil, nil, err
}
defer func() {
_ = workbook.Close()
}()
sheetName, err := resolveSheetName(workbook, requestedSheet)
if err != nil {
return "", nil, nil, err
}
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return "", nil, nil, err
}
if len(allRows) == 0 {
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
}
indexes, headerIssues := parseHeaderIndexes(allRows[0])
if len(headerIssues) > 0 {
return sheetName, nil, headerIssues, nil
}
rowsByAdjustmentID := make(map[uint]adjustmentPriceImportRow)
issues := make([]validationIssue, 0)
for idx := 1; idx < len(allRows); idx++ {
rowNumber := idx + 1
rawRow := allRows[idx]
if isRowEmpty(rawRow) {
continue
}
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes)
if len(rowIssues) > 0 {
issues = append(issues, rowIssues...)
continue
}
rowsByAdjustmentID[parsed.AdjustmentID] = *parsed
}
rows := make([]adjustmentPriceImportRow, 0, len(rowsByAdjustmentID))
for _, row := range rowsByAdjustmentID {
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].RowNumber < rows[j].RowNumber
})
if len(rows) == 0 && len(issues) == 0 {
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
}
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
if workbook == nil {
return "", fmt.Errorf("workbook is nil")
}
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if requestedSheet == "" {
return sheets[0], nil
}
for _, sheet := range sheets {
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
return sheet, nil
}
}
return "", fmt.Errorf("sheet %q not found", requestedSheet)
}
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
indexes := headerIndexes{AdjustmentID: -1, Weight: -1}
issues := make([]validationIssue, 0)
for idx, raw := range headerRow {
header := normalizeHeader(raw)
if header == "" {
continue
}
switch header {
case "adjustment_id":
if indexes.AdjustmentID >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header adjustment_id"})
}
indexes.AdjustmentID = idx
case "weight":
if indexes.Weight >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header weight"})
}
indexes.Weight = idx
}
}
if indexes.AdjustmentID < 0 {
issues = append(issues, validationIssue{Field: "adjustment_id", Message: "required header is missing"})
}
if indexes.Weight < 0 {
issues = append(issues, validationIssue{Field: "weight", Message: "required header is missing"})
}
return indexes, issues
}
func parseDataRow(
rawRow []string,
rowNumber int,
indexes headerIndexes,
) (*adjustmentPriceImportRow, []validationIssue) {
issues := make([]validationIssue, 0)
adjustmentIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.AdjustmentID))
adjustmentID, err := parsePositiveUint(adjustmentIDRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "adjustment_id", Message: err.Error()})
}
weightRaw := strings.TrimSpace(cellValue(rawRow, indexes.Weight))
weight, err := parseNonNegativeFloat(weightRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "weight", Message: err.Error()})
}
if len(issues) > 0 {
return nil, issues
}
return &adjustmentPriceImportRow{
RowNumber: rowNumber,
AdjustmentID: adjustmentID,
Weight: weight,
}, nil
}
func parsePositiveUint(raw string) (uint, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
uintValue, err := strconv.ParseUint(raw, 10, 64)
if err == nil {
if uintValue == 0 {
return 0, fmt.Errorf("must be greater than 0")
}
return uint(uintValue), nil
}
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, fmt.Errorf("must be a positive integer")
}
if floatValue <= 0 {
return 0, fmt.Errorf("must be greater than 0")
}
if floatValue != float64(uint(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
return uint(floatValue), nil
}
func parseNonNegativeFloat(raw string) (float64, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("must be numeric")
}
if value < 0 {
return 0, fmt.Errorf("must be greater than or equal to 0")
}
return value, nil
}
func isRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func normalizeHeader(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func collectAdjustmentIDs(rows []adjustmentPriceImportRow) []uint {
ids := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.AdjustmentID == 0 {
continue
}
if _, exists := seen[row.AdjustmentID]; exists {
continue
}
seen[row.AdjustmentID] = struct{}{}
ids = append(ids, row.AdjustmentID)
}
sort.Slice(ids, func(i, j int) bool {
return ids[i] < ids[j]
})
return ids
}
func (r dbAdjustmentResolver) ResolveExistingAdjustmentIDs(
ctx context.Context,
adjustmentIDs []uint,
) (map[uint]struct{}, error) {
result := make(map[uint]struct{})
if len(adjustmentIDs) == 0 {
return result, nil
}
type adjustmentIDRow struct {
ID uint `gorm:"column:id"`
}
rows := make([]adjustmentIDRow, 0, len(adjustmentIDs))
if err := r.db.WithContext(ctx).
Table("adjustment_stocks").
Select("id").
Where("id IN ?", adjustmentIDs).
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = struct{}{}
}
return result, nil
}
func splitRowsByExistingIDs(
rows []adjustmentPriceImportRow,
existing map[uint]struct{},
) ([]adjustmentPriceImportRow, []adjustmentPriceImportRow) {
processable := make([]adjustmentPriceImportRow, 0, len(rows))
skipped := make([]adjustmentPriceImportRow, 0)
for _, row := range rows {
if _, exists := existing[row.AdjustmentID]; exists {
processable = append(processable, row)
continue
}
skipped = append(skipped, row)
}
return processable, skipped
}
func printPlanRows(rows []adjustmentPriceImportRow) {
for _, row := range rows {
fmt.Printf(
"PLAN row=%d adjustment_id=%d price=%.3f\n",
row.RowNumber,
row.AdjustmentID,
row.Weight,
)
}
}
func printSkippedRows(rows []adjustmentPriceImportRow) {
for _, row := range rows {
fmt.Printf(
"SKIP row=%d adjustment_id=%d reason=adjustment_id not found\n",
row.RowNumber,
row.AdjustmentID,
)
}
}
func sortValidationIssues(issues []validationIssue) {
sort.Slice(issues, func(i, j int) bool {
if issues[i].Row == issues[j].Row {
if issues[i].Field == issues[j].Field {
return issues[i].Message < issues[j].Message
}
return issues[i].Field < issues[j].Field
}
return issues[i].Row < issues[j].Row
})
}
func applyIfRequested(
ctx context.Context,
apply bool,
runner txRunner,
rows []adjustmentPriceImportRow,
) ([]applyRowResult, error) {
if !apply || len(rows) == 0 {
return nil, nil
}
return applyImportRows(ctx, runner, rows)
}
func applyImportRows(
ctx context.Context,
runner txRunner,
rows []adjustmentPriceImportRow,
) ([]applyRowResult, error) {
results := make([]applyRowResult, 0, len(rows))
err := runner.InTx(ctx, func(store adjustmentPriceStore) error {
for _, row := range rows {
changed, err := store.UpdatePrice(ctx, row.AdjustmentID, row.Weight)
if err != nil {
return fmt.Errorf("row %d adjustment_id=%d update failed: %w", row.RowNumber, row.AdjustmentID, err)
}
results = append(results, applyRowResult{
RowNumber: row.RowNumber,
AdjustmentID: row.AdjustmentID,
Price: row.Weight,
Changed: changed,
})
}
return nil
})
if err != nil {
return nil, err
}
return results, nil
}
func (r dbTxRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(dbAdjustmentPriceStore{db: tx})
})
}
func (s dbAdjustmentPriceStore) UpdatePrice(
ctx context.Context,
adjustmentID uint,
price float64,
) (bool, error) {
result := s.db.WithContext(ctx).Exec(`
UPDATE adjustment_stocks
SET price = ?,
updated_at = NOW()
WHERE id = ?
AND price IS DISTINCT FROM ?
`, price, adjustmentID, price)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func applyStatus(changed bool) string {
if changed {
return "UPDATED"
}
return "UNCHANGED"
}
func countChangedRows(results []applyRowResult) int {
count := 0
for _, result := range results {
if result.Changed {
count++
}
}
return count
}
@@ -0,0 +1,362 @@
package main
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
func TestParseAdjustmentPriceFile_ValidSingleRow(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"101", "12.345"}},
)
sheet, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "adjustment_prices" {
t.Fatalf("expected selected sheet adjustment_prices, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].AdjustmentID != 101 {
t.Fatalf("expected adjustment_id 101, got %d", rows[0].AdjustmentID)
}
if rows[0].Weight != 12.345 {
t.Fatalf("expected weight 12.345, got %v", rows[0].Weight)
}
}
func TestParseAdjustmentPriceFile_ValidMultiRow(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{" Adjustment_ID ", "WEIGHT"},
[][]string{{"101", "10"}, {"102", "11.5"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "adjustment_prices")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
}
func TestParseAdjustmentPriceFile_MissingRequiredHeader(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "price"},
[][]string{{"101", "12"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
}
if !hasIssue(issues, 0, "weight", "required header is missing") {
t.Fatalf("expected missing weight header issue, got %+v", issues)
}
}
func TestParseAdjustmentPriceFile_InvalidAdjustmentID(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"abc", "10"}, {"0", "12"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "adjustment_id", "must be a positive integer") {
t.Fatalf("expected non numeric adjustment_id issue, got %+v", issues)
}
if !hasIssue(issues, 3, "adjustment_id", "must be greater than 0") {
t.Fatalf("expected adjustment_id >0 issue, got %+v", issues)
}
}
func TestParseAdjustmentPriceFile_InvalidWeight(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"101", "abc"}, {"102", "-1"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "weight", "must be numeric") {
t.Fatalf("expected weight numeric issue, got %+v", issues)
}
if !hasIssue(issues, 3, "weight", "must be greater than or equal to 0") {
t.Fatalf("expected weight >=0 issue, got %+v", issues)
}
}
func TestParseAdjustmentPriceFile_DuplicateAdjustmentID_LastRowWins(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"101", "10"}, {"102", "20"}, {"101", "30"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 deduped rows, got %d", len(rows))
}
row101, ok := findRowByAdjustmentID(rows, 101)
if !ok {
t.Fatalf("expected adjustment_id 101 to exist, got %+v", rows)
}
if row101.Weight != 30 {
t.Fatalf("expected duplicate adjustment_id to keep last weight 30, got %v", row101.Weight)
}
if row101.RowNumber != 4 {
t.Fatalf("expected duplicate adjustment_id to keep last row number 4, got %d", row101.RowNumber)
}
}
func TestSplitRowsByExistingIDs_SkipMissing(t *testing.T) {
rows := []adjustmentPriceImportRow{
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
{RowNumber: 4, AdjustmentID: 103, Weight: 12},
}
existing := map[uint]struct{}{101: {}, 103: {}}
processable, skipped := splitRowsByExistingIDs(rows, existing)
if len(processable) != 2 {
t.Fatalf("expected 2 processable rows, got %d", len(processable))
}
if len(skipped) != 1 {
t.Fatalf("expected 1 skipped row, got %d", len(skipped))
}
if skipped[0].AdjustmentID != 102 {
t.Fatalf("expected adjustment_id 102 skipped, got %+v", skipped)
}
}
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
runner := &fakeTransactionRunner{}
rows := []adjustmentPriceImportRow{{RowNumber: 2, AdjustmentID: 101, Weight: 10}}
results, err := applyIfRequested(context.Background(), false, runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if results != nil {
t.Fatalf("expected nil results on dry-run, got %+v", results)
}
if runner.txCalls != 0 {
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
}
}
func TestApplyImportRows_Success(t *testing.T) {
runner := &fakeTransactionRunner{
changedByID: map[uint]bool{101: true, 102: false},
}
rows := []adjustmentPriceImportRow{
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
}
results, err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedCalls) != 2 {
t.Fatalf("expected 2 committed updates, got %d", len(runner.committedCalls))
}
if len(results) != 2 {
t.Fatalf("expected 2 row results, got %d", len(results))
}
if !results[0].Changed || results[1].Changed {
t.Fatalf("unexpected changed flags: %+v", results)
}
}
func TestApplyImportRows_RollbackOnError(t *testing.T) {
runner := &fakeTransactionRunner{
errByID: map[uint]error{102: errors.New("boom")},
}
rows := []adjustmentPriceImportRow{
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
}
_, err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected error due to update failure")
}
if !strings.Contains(err.Error(), "row 3 adjustment_id=102 update failed") {
t.Fatalf("unexpected error message: %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedCalls) != 0 {
t.Fatalf("expected no committed updates on rollback, got %d", len(runner.committedCalls))
}
}
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
if sheetName == "" {
sheetName = defaultSheet
} else if sheetName != defaultSheet {
f.SetSheetName(defaultSheet, sheetName)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed resolving header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed resolving data cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting data cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "adjustment_prices.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed closing workbook: %v", err)
}
return path
}
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, messageContains) {
return true
}
}
return false
}
func findRowByAdjustmentID(rows []adjustmentPriceImportRow, adjustmentID uint) (adjustmentPriceImportRow, bool) {
for _, row := range rows {
if row.AdjustmentID == adjustmentID {
return row, true
}
}
return adjustmentPriceImportRow{}, false
}
type updateCall struct {
adjustmentID uint
price float64
}
type fakeAdjustmentPriceStore struct {
changedByID map[uint]bool
errByID map[uint]error
calls []updateCall
}
func (s *fakeAdjustmentPriceStore) UpdatePrice(_ context.Context, adjustmentID uint, price float64) (bool, error) {
s.calls = append(s.calls, updateCall{adjustmentID: adjustmentID, price: price})
if err, exists := s.errByID[adjustmentID]; exists {
return false, fmt.Errorf("forced update failure for adjustment_id=%d: %w", adjustmentID, err)
}
if changed, exists := s.changedByID[adjustmentID]; exists {
return changed, nil
}
return true, nil
}
type fakeTransactionRunner struct {
txCalls int
changedByID map[uint]bool
errByID map[uint]error
committedCalls []updateCall
}
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error {
r.txCalls++
txStore := &fakeAdjustmentPriceStore{
changedByID: r.changedByID,
errByID: r.errByID,
calls: make([]updateCall, 0),
}
if err := fn(txStore); err != nil {
return err
}
r.committedCalls = append(r.committedCalls, txStore.calls...)
return nil
}
var _ txRunner = (*fakeTransactionRunner)(nil)
var _ adjustmentPriceStore = (*fakeAdjustmentPriceStore)(nil)
@@ -0,0 +1,632 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
const dateLayout = "2006-01-02"
type importOptions struct {
FilePath string
Sheet string
Apply bool
}
type headerIndexes struct {
ProjectFlockID int
TotalCost int
CutoverDate int
Note int
}
type manualInputImportRow struct {
RowNumber int
ProjectFlockID uint
TotalCost float64
CutoverDate time.Time
Note *string
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) Error() string {
if i.Row > 0 {
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
}
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
}
type farmResolver interface {
ResolveActiveLayingFarms(ctx context.Context, projectFlockIDs []uint) (map[uint]string, error)
}
type dbFarmResolver struct {
db *gorm.DB
}
type manualInputStore interface {
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error
}
type txRunner interface {
InTx(ctx context.Context, fn func(store manualInputStore) error) error
}
type dbTxRunner struct {
db *gorm.DB
}
type expenseDepreciationStore struct {
repo repportRepo.ExpenseDepreciationRepository
}
type farmIdentityRow struct {
ID uint `gorm:"column:id"`
FarmName string `gorm:"column:farm_name"`
}
func main() {
var opts importOptions
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
log.Fatalf("failed to load timezone Asia/Jakarta: %v", err)
}
sheetName, rows, parseIssues, err := parseManualInputFile(opts.FilePath, opts.Sheet, location)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
resolver := dbFarmResolver{db: db}
farmNameByID, err := resolver.ResolveActiveLayingFarms(ctx, collectProjectFlockIDs(rows))
if err != nil {
log.Fatalf("failed validating project_flock_id against project_flocks: %v", err)
}
issues := append([]validationIssue{}, parseIssues...)
issues = append(issues, buildMissingFarmIssues(rows, farmNameByID)...)
sortValidationIssues(issues)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("Sheet: %s\n", sheetName)
fmt.Printf("Rows parsed: %d\n", len(rows))
fmt.Printf("Rows invalid: %d\n", len(issues))
fmt.Println()
if len(rows) > 0 {
printPlanRows(rows, farmNameByID)
fmt.Println()
}
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.Error())
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=0 failed=%d\n", len(rows), len(issues))
os.Exit(1)
}
if !opts.Apply {
fmt.Printf("Summary: planned=%d applied=0 failed=0\n", len(rows))
return
}
if len(rows) == 0 {
fmt.Println("Summary: planned=0 applied=0 failed=0")
return
}
if err := applyIfRequested(ctx, true, dbTxRunner{db: db}, rows); err != nil {
log.Fatalf("apply failed: %v", err)
}
for _, row := range rows {
fmt.Printf(
"DONE row=%d project_flock_id=%d cutover_date=%s\n",
row.RowNumber,
row.ProjectFlockID,
row.CutoverDate.In(location).Format(dateLayout),
)
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=%d failed=0\n", len(rows), len(rows))
}
func parseManualInputFile(
filePath string,
requestedSheet string,
location *time.Location,
) (string, []manualInputImportRow, []validationIssue, error) {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return "", nil, nil, err
}
defer func() {
_ = workbook.Close()
}()
sheetName, err := resolveSheetName(workbook, requestedSheet)
if err != nil {
return "", nil, nil, err
}
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return "", nil, nil, err
}
if len(allRows) == 0 {
return sheetName, nil, []validationIssue{
{Field: "header", Message: "sheet is empty"},
}, nil
}
indexes, headerIssues := parseHeaderIndexes(allRows[0])
if len(headerIssues) > 0 {
return sheetName, nil, headerIssues, nil
}
rows := make([]manualInputImportRow, 0, len(allRows)-1)
issues := make([]validationIssue, 0)
seenProjectFlockIDs := make(map[uint]int)
for idx := 1; idx < len(allRows); idx++ {
rowNumber := idx + 1
rawRow := allRows[idx]
if isRowEmpty(rawRow) {
continue
}
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, location, seenProjectFlockIDs)
if len(rowIssues) > 0 {
issues = append(issues, rowIssues...)
continue
}
rows = append(rows, *parsed)
}
if len(rows) == 0 && len(issues) == 0 {
issues = append(issues, validationIssue{
Field: "rows",
Message: "no data rows found",
})
}
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
if workbook == nil {
return "", fmt.Errorf("workbook is nil")
}
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if requestedSheet == "" {
return sheets[0], nil
}
for _, sheet := range sheets {
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
return sheet, nil
}
}
return "", fmt.Errorf("sheet %q not found", requestedSheet)
}
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
indexes := headerIndexes{
ProjectFlockID: -1,
TotalCost: -1,
CutoverDate: -1,
Note: -1,
}
issues := make([]validationIssue, 0)
for idx, raw := range headerRow {
header := normalizeHeader(raw)
if header == "" {
continue
}
switch header {
case "project_flock_id":
if indexes.ProjectFlockID >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header project_flock_id",
})
}
indexes.ProjectFlockID = idx
case "total_cost":
if indexes.TotalCost >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header total_cost",
})
}
indexes.TotalCost = idx
case "cutover_date":
if indexes.CutoverDate >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header cutover_date",
})
}
indexes.CutoverDate = idx
case "note":
if indexes.Note >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header note",
})
}
indexes.Note = idx
}
}
if indexes.ProjectFlockID < 0 {
issues = append(issues, validationIssue{
Field: "project_flock_id",
Message: "required header is missing",
})
}
if indexes.TotalCost < 0 {
issues = append(issues, validationIssue{
Field: "total_cost",
Message: "required header is missing",
})
}
if indexes.CutoverDate < 0 {
issues = append(issues, validationIssue{
Field: "cutover_date",
Message: "required header is missing",
})
}
return indexes, issues
}
func parseDataRow(
rawRow []string,
rowNumber int,
indexes headerIndexes,
location *time.Location,
seenProjectFlockIDs map[uint]int,
) (*manualInputImportRow, []validationIssue) {
issues := make([]validationIssue, 0)
projectFlockIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.ProjectFlockID))
projectFlockID, err := parsePositiveUint(projectFlockIDRaw)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "project_flock_id",
Message: err.Error(),
})
}
totalCostRaw := strings.TrimSpace(cellValue(rawRow, indexes.TotalCost))
totalCost, err := parseNonNegativeFloat(totalCostRaw)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "total_cost",
Message: err.Error(),
})
}
cutoverDateRaw := strings.TrimSpace(cellValue(rawRow, indexes.CutoverDate))
cutoverDate, err := parseDateOnlyInLocation(cutoverDateRaw, location)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "cutover_date",
Message: err.Error(),
})
}
var note *string
noteRaw := strings.TrimSpace(cellValue(rawRow, indexes.Note))
if noteRaw != "" {
if len([]rune(noteRaw)) > 1000 {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "note",
Message: "must have at most 1000 characters",
})
} else {
note = &noteRaw
}
}
if projectFlockID > 0 {
if previousRow, exists := seenProjectFlockIDs[projectFlockID]; exists {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "project_flock_id",
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", projectFlockID, previousRow),
})
} else {
seenProjectFlockIDs[projectFlockID] = rowNumber
}
}
if len(issues) > 0 {
return nil, issues
}
return &manualInputImportRow{
RowNumber: rowNumber,
ProjectFlockID: projectFlockID,
TotalCost: totalCost,
CutoverDate: cutoverDate,
Note: note,
}, nil
}
func parsePositiveUint(raw string) (uint, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
uintValue, err := strconv.ParseUint(raw, 10, 64)
if err == nil {
if uintValue == 0 {
return 0, fmt.Errorf("must be greater than 0")
}
return uint(uintValue), nil
}
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, fmt.Errorf("must be a positive integer")
}
if floatValue <= 0 {
return 0, fmt.Errorf("must be greater than 0")
}
if floatValue != float64(uint(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
return uint(floatValue), nil
}
func parseNonNegativeFloat(raw string) (float64, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("must be numeric")
}
if value < 0 {
return 0, fmt.Errorf("must be greater than or equal to 0")
}
return value, nil
}
func parseDateOnlyInLocation(raw string, location *time.Location) (time.Time, error) {
if raw == "" {
return time.Time{}, fmt.Errorf("is required")
}
value, err := time.ParseInLocation(dateLayout, raw, location)
if err != nil {
return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD")
}
return value, nil
}
func isRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func normalizeHeader(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func collectProjectFlockIDs(rows []manualInputImportRow) []uint {
ids := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.ProjectFlockID == 0 {
continue
}
if _, exists := seen[row.ProjectFlockID]; exists {
continue
}
seen[row.ProjectFlockID] = struct{}{}
ids = append(ids, row.ProjectFlockID)
}
sort.Slice(ids, func(i, j int) bool {
return ids[i] < ids[j]
})
return ids
}
func (r dbFarmResolver) ResolveActiveLayingFarms(
ctx context.Context,
projectFlockIDs []uint,
) (map[uint]string, error) {
result := make(map[uint]string)
if len(projectFlockIDs) == 0 {
return result, nil
}
rows := make([]farmIdentityRow, 0, len(projectFlockIDs))
if err := r.db.WithContext(ctx).
Table("project_flocks").
Select("id, flock_name AS farm_name").
Where("id IN ?", projectFlockIDs).
Where("deleted_at IS NULL").
Where("category = ?", utils.ProjectFlockCategoryLaying).
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = row.FarmName
}
return result, nil
}
func buildMissingFarmIssues(rows []manualInputImportRow, farmNameByID map[uint]string) []validationIssue {
issues := make([]validationIssue, 0)
for _, row := range rows {
if _, exists := farmNameByID[row.ProjectFlockID]; exists {
continue
}
issues = append(issues, validationIssue{
Row: row.RowNumber,
Field: "project_flock_id",
Message: fmt.Sprintf("value %d must reference an active LAYING project_flock", row.ProjectFlockID),
})
}
return issues
}
func printPlanRows(rows []manualInputImportRow, farmNameByID map[uint]string) {
for _, row := range rows {
farmName := farmNameByID[row.ProjectFlockID]
fmt.Printf(
"PLAN row=%d project_flock_id=%d farm_name=%q total_cost=%.3f cutover_date=%s note=%q\n",
row.RowNumber,
row.ProjectFlockID,
farmName,
row.TotalCost,
row.CutoverDate.Format(dateLayout),
derefString(row.Note),
)
}
}
func sortValidationIssues(issues []validationIssue) {
sort.Slice(issues, func(i, j int) bool {
if issues[i].Row == issues[j].Row {
if issues[i].Field == issues[j].Field {
return issues[i].Message < issues[j].Message
}
return issues[i].Field < issues[j].Field
}
return issues[i].Row < issues[j].Row
})
}
func applyIfRequested(ctx context.Context, apply bool, runner txRunner, rows []manualInputImportRow) error {
if !apply || len(rows) == 0 {
return nil
}
return applyImportRows(ctx, runner, rows)
}
func applyImportRows(ctx context.Context, runner txRunner, rows []manualInputImportRow) error {
return runner.InTx(ctx, func(store manualInputStore) error {
for _, row := range rows {
payload := entity.FarmDepreciationManualInput{
ProjectFlockId: row.ProjectFlockID,
TotalCost: row.TotalCost,
CutoverDate: row.CutoverDate,
Note: row.Note,
}
if err := store.UpsertManualInput(ctx, &payload); err != nil {
return fmt.Errorf("row %d project_flock_id=%d upsert failed: %w", row.RowNumber, row.ProjectFlockID, err)
}
if err := store.DeleteSnapshotsFromDate(ctx, row.CutoverDate, []uint{row.ProjectFlockID}); err != nil {
return fmt.Errorf("row %d project_flock_id=%d snapshot invalidation failed: %w", row.RowNumber, row.ProjectFlockID, err)
}
}
return nil
})
}
func (r dbTxRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
repo := repportRepo.NewExpenseDepreciationRepository(tx)
store := expenseDepreciationStore{repo: repo}
return fn(store)
})
}
func (s expenseDepreciationStore) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error {
return s.repo.UpsertManualInput(ctx, row)
}
func (s expenseDepreciationStore) DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error {
return s.repo.DeleteSnapshotsFromDate(ctx, fromDate, farmIDs)
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
@@ -0,0 +1,563 @@
package main
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/xuri/excelize/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func TestParseManualInputFile_ValidSingleRow(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "12345.678", "2026-06-01", "manual seed"},
},
)
location := mustJakartaLocation(t)
sheet, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "manual_inputs" {
t.Fatalf("expected selected sheet manual_inputs, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].ProjectFlockID != 101 {
t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID)
}
if rows[0].TotalCost != 12345.678 {
t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost)
}
if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" {
t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout))
}
if rows[0].Note == nil || *rows[0].Note != "manual seed" {
t.Fatalf("expected note manual seed, got %+v", rows[0].Note)
}
}
func TestParseManualInputFile_ValidMultiRow(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"102", "1300.5", "2026-06-02", "second"},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0].Note != nil {
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
}
if rows[1].Note == nil || *rows[1].Note != "second" {
t.Fatalf("expected second row note second, got %+v", rows[1].Note)
}
}
func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "totalcost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
}
if !hasIssue(issues, 0, "total_cost", "required header is missing") {
t.Fatalf("expected missing total_cost header issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"abc", "1200", "2026-06-01", ""},
{"0", "1300", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") {
t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues)
}
if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") {
t.Fatalf("expected project_flock_id >0 issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidTotalCost(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "abc", "2026-06-01", ""},
{"102", "-1", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "total_cost", "must be numeric") {
t.Fatalf("expected total_cost numeric issue, got %+v", issues)
}
if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") {
t.Fatalf("expected total_cost >=0 issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "06-01-2026", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") {
t.Fatalf("expected cutover_date format issue, got %+v", issues)
}
}
func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"101", "1300", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows))
}
if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") {
t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues)
}
}
func TestParseManualInputFile_NoteValidation(t *testing.T) {
longNote := strings.Repeat("a", 1001)
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"102", "1300", "2026-06-02", longNote},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected only first row valid, got %d", len(rows))
}
if rows[0].Note != nil {
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
}
if !hasIssue(issues, 3, "note", "at most 1000 characters") {
t.Fatalf("expected note length issue, got %+v", issues)
}
}
func TestApplyImportRows_Success(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 2000,
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedUpserts) != 2 {
t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts))
}
if len(runner.committedInvalidations) != 2 {
t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations))
}
if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 {
t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations)
}
}
func TestApplyImportRows_RollbackOnError(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{
failUpsertOnProjectFlockID: 102,
}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 2000,
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected error due to upsert failure")
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedUpserts) != 0 {
t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts))
}
if len(runner.committedInvalidations) != 0 {
t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations))
}
}
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
err := applyIfRequested(context.Background(), false, runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 0 {
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
}
}
func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
if sheetName == "" {
sheetName = defaultSheet
} else if sheetName != defaultSheet {
f.SetSheetName(defaultSheet, sheetName)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed resolving header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed resolving data cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting data cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "manual_inputs.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed closing workbook: %v", err)
}
return path
}
func mustJakartaLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading Asia/Jakarta location: %v", err)
}
return location
}
func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time {
t.Helper()
value, err := time.ParseInLocation(dateLayout, raw, location)
if err != nil {
t.Fatalf("failed parsing date %q: %v", raw, err)
}
return value
}
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, messageContains) {
return true
}
}
return false
}
type fakeInvalidation struct {
fromDate time.Time
farmIDs []uint
}
type fakeManualInputStore struct {
failUpsertOnProjectFlockID uint
failDeleteOnProjectFlockID uint
upserts []entity.FarmDepreciationManualInput
invalidations []fakeInvalidation
}
func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error {
if row == nil {
return nil
}
if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID {
return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId)
}
cloned := *row
s.upserts = append(s.upserts, cloned)
return nil
}
func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
if s.failDeleteOnProjectFlockID > 0 {
for _, farmID := range farmIDs {
if farmID == s.failDeleteOnProjectFlockID {
return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID)
}
}
}
copiedFarmIDs := append([]uint{}, farmIDs...)
s.invalidations = append(s.invalidations, fakeInvalidation{
fromDate: fromDate,
farmIDs: copiedFarmIDs,
})
return nil
}
type fakeTransactionRunner struct {
txCalls int
failUpsertOnProjectFlockID uint
failDeleteOnProjectFlockID uint
committedUpserts []entity.FarmDepreciationManualInput
committedInvalidations []fakeInvalidation
}
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
r.txCalls++
txStore := &fakeManualInputStore{
failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID,
failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID,
}
if err := fn(txStore); err != nil {
return err
}
r.committedUpserts = append(r.committedUpserts, txStore.upserts...)
r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...)
return nil
}
var _ txRunner = (*fakeTransactionRunner)(nil)
var _ manualInputStore = (*fakeManualInputStore)(nil)
func TestBuildMissingFarmIssues(t *testing.T) {
location := mustJakartaLocation(t)
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
issues := buildMissingFarmIssues(rows, map[uint]string{
101: "Farm A",
})
if len(issues) != 1 {
t.Fatalf("expected 1 issue, got %+v", issues)
}
if issues[0].Row != 3 || issues[0].Field != "project_flock_id" {
t.Fatalf("unexpected issue: %+v", issues[0])
}
}
func TestApplyImportRows_PropagatesDeleteError(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{
failDeleteOnProjectFlockID: 101,
}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected delete failure")
}
if !strings.Contains(err.Error(), "snapshot invalidation failed") {
t.Fatalf("expected snapshot invalidation error message, got %v", err)
}
}
func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) {
workbook := excelize.NewFile()
defer func() {
_ = workbook.Close()
}()
_, err := resolveSheetName(workbook, "unknown")
if err == nil {
t.Fatal("expected error when sheet is missing")
}
}
func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) {
location := mustJakartaLocation(t)
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
runner := &errorTxRunner{err: errors.New("tx failed")}
err := applyIfRequested(context.Background(), true, runner, rows)
if err == nil {
t.Fatal("expected transaction error")
}
if err.Error() != "tx failed" {
t.Fatalf("unexpected error: %v", err)
}
}
type errorTxRunner struct {
err error
}
func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error {
return r.err
}
+602
View File
@@ -0,0 +1,602 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
type importOptions struct {
FilePath string
Sheet string
Apply bool
}
type headerIndexes struct {
KandangID int
KandangName int
HouseType int
}
type kandangHouseTypeImportRow struct {
RowNumber int
KandangID uint
KandangName string
HouseType string
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) Error() string {
if i.Row > 0 {
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
}
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
}
type kandangResolver interface {
ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error)
}
type dbKandangResolver struct {
db *gorm.DB
}
type txRunner interface {
InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error
}
type dbTxRunner struct {
db *gorm.DB
}
type kandangHouseTypeStore interface {
UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error)
NormalizeNullHouseType(ctx context.Context) (int64, error)
}
type dbKandangHouseTypeStore struct {
db *gorm.DB
}
type kandangIdentityRow struct {
ID uint `gorm:"column:id"`
Name string `gorm:"column:name"`
}
type applyRowResult struct {
RowNumber int
KandangID uint
HouseType string
Changed bool
}
func main() {
var opts importOptions
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
sheetName, rows, parseIssues, err := parseKandangHouseTypeFile(opts.FilePath, opts.Sheet)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
resolver := dbKandangResolver{db: db}
kandangNameByID, err := resolver.ResolveActiveKandangs(ctx, collectKandangIDs(rows))
if err != nil {
log.Fatalf("failed validating kandang_id against kandangs: %v", err)
}
issues := append([]validationIssue{}, parseIssues...)
issues = append(issues, buildMissingKandangIssues(rows, kandangNameByID)...)
issues = append(issues, buildNameMismatchIssues(rows, kandangNameByID)...)
sortValidationIssues(issues)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("Sheet: %s\n", sheetName)
fmt.Printf("Rows parsed: %d\n", len(rows))
fmt.Printf("Rows invalid: %d\n", len(issues))
fmt.Println()
if len(rows) > 0 {
printPlanRows(rows, kandangNameByID)
fmt.Println()
}
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.Error())
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=%d\n", len(rows), len(issues))
os.Exit(1)
}
if !opts.Apply {
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=0\n", len(rows))
return
}
rowResults, normalizedCount, err := applyImportRows(ctx, dbTxRunner{db: db}, rows)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
for _, result := range rowResults {
fmt.Printf(
"DONE row=%d kandang_id=%d house_type=%s status=%s\n",
result.RowNumber,
result.KandangID,
result.HouseType,
applyStatus(result.Changed),
)
}
appliedCount := countChangedRows(rowResults)
fmt.Println()
fmt.Printf(
"Summary: planned=%d applied=%d normalized_null_to_open_house=%d failed=0\n",
len(rows),
appliedCount,
normalizedCount,
)
}
func parseKandangHouseTypeFile(
filePath string,
requestedSheet string,
) (string, []kandangHouseTypeImportRow, []validationIssue, error) {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return "", nil, nil, err
}
defer func() {
_ = workbook.Close()
}()
sheetName, err := resolveSheetName(workbook, requestedSheet)
if err != nil {
return "", nil, nil, err
}
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return "", nil, nil, err
}
if len(allRows) == 0 {
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
}
indexes, headerIssues := parseHeaderIndexes(allRows[0])
if len(headerIssues) > 0 {
return sheetName, nil, headerIssues, nil
}
rows := make([]kandangHouseTypeImportRow, 0, len(allRows)-1)
issues := make([]validationIssue, 0)
seenKandangIDs := make(map[uint]int)
for idx := 1; idx < len(allRows); idx++ {
rowNumber := idx + 1
rawRow := allRows[idx]
if isRowEmpty(rawRow) {
continue
}
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, seenKandangIDs)
if len(rowIssues) > 0 {
issues = append(issues, rowIssues...)
continue
}
rows = append(rows, *parsed)
}
if len(rows) == 0 && len(issues) == 0 {
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
}
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
if workbook == nil {
return "", fmt.Errorf("workbook is nil")
}
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if requestedSheet == "" {
return sheets[0], nil
}
for _, sheet := range sheets {
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
return sheet, nil
}
}
return "", fmt.Errorf("sheet %q not found", requestedSheet)
}
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
indexes := headerIndexes{KandangID: -1, KandangName: -1, HouseType: -1}
issues := make([]validationIssue, 0)
for idx, raw := range headerRow {
header := normalizeHeader(raw)
if header == "" {
continue
}
switch header {
case "kandang_id":
if indexes.KandangID >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_id"})
}
indexes.KandangID = idx
case "kandang_name":
if indexes.KandangName >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_name"})
}
indexes.KandangName = idx
case "house_type", "type_house":
if indexes.HouseType >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header house_type"})
}
indexes.HouseType = idx
}
}
if indexes.KandangID < 0 {
issues = append(issues, validationIssue{Field: "kandang_id", Message: "required header is missing"})
}
if indexes.KandangName < 0 {
issues = append(issues, validationIssue{Field: "kandang_name", Message: "required header is missing"})
}
if indexes.HouseType < 0 {
issues = append(issues, validationIssue{Field: "house_type", Message: "required header is missing"})
}
return indexes, issues
}
func parseDataRow(
rawRow []string,
rowNumber int,
indexes headerIndexes,
seenKandangIDs map[uint]int,
) (*kandangHouseTypeImportRow, []validationIssue) {
issues := make([]validationIssue, 0)
kandangIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangID))
kandangID, err := parsePositiveUint(kandangIDRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_id", Message: err.Error()})
}
kandangNameRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangName))
if kandangNameRaw == "" {
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_name", Message: "is required"})
}
houseTypeRaw := strings.TrimSpace(cellValue(rawRow, indexes.HouseType))
houseType, err := normalizeHouseType(houseTypeRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "house_type", Message: err.Error()})
}
if kandangID > 0 {
if previousRow, exists := seenKandangIDs[kandangID]; exists {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "kandang_id",
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", kandangID, previousRow),
})
} else {
seenKandangIDs[kandangID] = rowNumber
}
}
if len(issues) > 0 {
return nil, issues
}
return &kandangHouseTypeImportRow{
RowNumber: rowNumber,
KandangID: kandangID,
KandangName: kandangNameRaw,
HouseType: houseType,
}, nil
}
func normalizeHouseType(raw string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
if normalized == "" {
return string(utils.HouseTypeOpenHouse), nil
}
switch normalized {
case string(utils.HouseTypeOpenHouse), string(utils.HouseTypeCloseHouse):
return normalized, nil
default:
return "", fmt.Errorf("must be one of: open_house, close_house (or empty for default open_house)")
}
}
func parsePositiveUint(raw string) (uint, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
uintValue, err := strconv.ParseUint(raw, 10, 64)
if err == nil {
if uintValue == 0 {
return 0, fmt.Errorf("must be greater than 0")
}
return uint(uintValue), nil
}
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, fmt.Errorf("must be a positive integer")
}
if floatValue <= 0 {
return 0, fmt.Errorf("must be greater than 0")
}
if floatValue != float64(uint(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
return uint(floatValue), nil
}
func isRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func normalizeHeader(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func collectKandangIDs(rows []kandangHouseTypeImportRow) []uint {
ids := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.KandangID == 0 {
continue
}
if _, exists := seen[row.KandangID]; exists {
continue
}
seen[row.KandangID] = struct{}{}
ids = append(ids, row.KandangID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func (r dbKandangResolver) ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) {
result := make(map[uint]string)
if len(kandangIDs) == 0 {
return result, nil
}
rows := make([]kandangIdentityRow, 0, len(kandangIDs))
if err := r.db.WithContext(ctx).
Table("kandangs").
Select("id, name").
Where("id IN ?", kandangIDs).
Where("deleted_at IS NULL").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = row.Name
}
return result, nil
}
func buildMissingKandangIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
issues := make([]validationIssue, 0)
for _, row := range rows {
if _, exists := kandangNameByID[row.KandangID]; exists {
continue
}
issues = append(issues, validationIssue{
Row: row.RowNumber,
Field: "kandang_id",
Message: fmt.Sprintf("value %d must reference an active kandang", row.KandangID),
})
}
return issues
}
func buildNameMismatchIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
issues := make([]validationIssue, 0)
for _, row := range rows {
dbName, exists := kandangNameByID[row.KandangID]
if !exists {
continue
}
if strings.EqualFold(strings.TrimSpace(row.KandangName), strings.TrimSpace(dbName)) {
continue
}
issues = append(issues, validationIssue{
Row: row.RowNumber,
Field: "kandang_name",
Message: fmt.Sprintf("value %q does not match kandang_id %d name %q", row.KandangName, row.KandangID, dbName),
})
}
return issues
}
func printPlanRows(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) {
for _, row := range rows {
fmt.Printf(
"PLAN row=%d kandang_id=%d kandang_name_file=%q kandang_name_db=%q house_type=%q\n",
row.RowNumber,
row.KandangID,
row.KandangName,
kandangNameByID[row.KandangID],
row.HouseType,
)
}
}
func sortValidationIssues(issues []validationIssue) {
sort.Slice(issues, func(i, j int) bool {
if issues[i].Row == issues[j].Row {
if issues[i].Field == issues[j].Field {
return issues[i].Message < issues[j].Message
}
return issues[i].Field < issues[j].Field
}
return issues[i].Row < issues[j].Row
})
}
func applyImportRows(
ctx context.Context,
runner txRunner,
rows []kandangHouseTypeImportRow,
) ([]applyRowResult, int64, error) {
results := make([]applyRowResult, 0, len(rows))
normalizedNullCount := int64(0)
err := runner.InTx(ctx, func(store kandangHouseTypeStore) error {
for _, row := range rows {
changed, err := store.UpdateKandangHouseType(ctx, row.KandangID, row.HouseType)
if err != nil {
return fmt.Errorf("row %d kandang_id=%d update failed: %w", row.RowNumber, row.KandangID, err)
}
results = append(results, applyRowResult{
RowNumber: row.RowNumber,
KandangID: row.KandangID,
HouseType: row.HouseType,
Changed: changed,
})
}
normalized, err := store.NormalizeNullHouseType(ctx)
if err != nil {
return fmt.Errorf("normalize null house_type to open_house failed: %w", err)
}
normalizedNullCount = normalized
return nil
})
if err != nil {
return nil, 0, err
}
return results, normalizedNullCount, nil
}
func (r dbTxRunner) InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(dbKandangHouseTypeStore{db: tx})
})
}
func (s dbKandangHouseTypeStore) UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) {
result := s.db.WithContext(ctx).Exec(`
UPDATE kandangs
SET house_type = ?::house_type_enum,
updated_at = NOW()
WHERE id = ?
AND deleted_at IS NULL
AND house_type IS DISTINCT FROM ?::house_type_enum
`, houseType, kandangID, houseType)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
func (s dbKandangHouseTypeStore) NormalizeNullHouseType(ctx context.Context) (int64, error) {
result := s.db.WithContext(ctx).Exec(`
UPDATE kandangs
SET house_type = 'open_house'::house_type_enum,
updated_at = NOW()
WHERE deleted_at IS NULL
AND house_type IS NULL
`)
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func applyStatus(changed bool) string {
if changed {
return "UPDATED"
}
return "UNCHANGED"
}
func countChangedRows(results []applyRowResult) int {
count := 0
for _, item := range results {
if item.Changed {
count++
}
}
return count
}
+280
View File
@@ -0,0 +1,280 @@
package main
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
func TestParseKandangHouseTypeFile_ValidSingleRowAndDefaultHouseType(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "house_type"},
[][]string{{"101", "Kandang A1", ""}},
)
sheet, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "kandang_house_type" {
t.Fatalf("expected sheet kandang_house_type, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].KandangID != 101 {
t.Fatalf("expected kandang_id 101, got %d", rows[0].KandangID)
}
if rows[0].KandangName != "Kandang A1" {
t.Fatalf("expected kandang_name Kandang A1, got %q", rows[0].KandangName)
}
if rows[0].HouseType != "open_house" {
t.Fatalf("expected default house_type open_house, got %q", rows[0].HouseType)
}
}
func TestParseKandangHouseTypeFile_TypeHouseHeaderAlias(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "type_house"},
[][]string{{"101", "Kandang A1", "close_house"}},
)
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "kandang_house_type")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 || rows[0].HouseType != "close_house" {
t.Fatalf("expected parsed close_house row, got %+v", rows)
}
}
func TestParseKandangHouseTypeFile_InvalidHouseType(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "house_type"},
[][]string{{"101", "Kandang A1", "semi_house"}},
)
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "house_type", "must be one of") {
t.Fatalf("expected invalid house_type issue, got %+v", issues)
}
}
func TestParseKandangHouseTypeFile_DuplicateKandangID(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "house_type"},
[][]string{
{"101", "Kandang A1", "open_house"},
{"101", "Kandang A2", "close_house"},
},
)
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected first row valid and second invalid, got %d", len(rows))
}
if !hasIssue(issues, 3, "kandang_id", "duplicate value 101") {
t.Fatalf("expected duplicate kandang_id issue, got %+v", issues)
}
}
func TestBuildNameMismatchIssues(t *testing.T) {
rows := []kandangHouseTypeImportRow{{
RowNumber: 2,
KandangID: 10,
KandangName: "Kandang Salah",
HouseType: "open_house",
}}
issues := buildNameMismatchIssues(rows, map[uint]string{10: "Kandang Benar"})
if !hasIssue(issues, 2, "kandang_name", "does not match") {
t.Fatalf("expected name mismatch issue, got %+v", issues)
}
}
func TestApplyImportRows_Success(t *testing.T) {
store := &fakeStore{
changedByID: map[uint]bool{101: true, 102: false},
normalizeResult: 3,
}
runner := &fakeTransactionRunner{store: store}
rows := []kandangHouseTypeImportRow{
{RowNumber: 2, KandangID: 101, HouseType: "open_house"},
{RowNumber: 3, KandangID: 102, HouseType: "close_house"},
}
results, normalized, err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 tx call, got %d", runner.txCalls)
}
if len(results) != 2 {
t.Fatalf("expected 2 row results, got %d", len(results))
}
if normalized != 3 {
t.Fatalf("expected normalized count 3, got %d", normalized)
}
if !results[0].Changed || results[1].Changed {
t.Fatalf("unexpected changed flags: %+v", results)
}
if len(store.updateCalls) != 2 {
t.Fatalf("expected 2 update calls, got %d", len(store.updateCalls))
}
}
func TestApplyImportRows_FailOnUpdate(t *testing.T) {
store := &fakeStore{
updateErrByID: map[uint]error{101: errors.New("boom")},
}
runner := &fakeTransactionRunner{store: store}
rows := []kandangHouseTypeImportRow{{RowNumber: 2, KandangID: 101, HouseType: "open_house"}}
_, _, err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "update failed") {
t.Fatalf("expected update failed error, got %v", err)
}
}
func TestCountChangedRows(t *testing.T) {
count := countChangedRows([]applyRowResult{{Changed: true}, {Changed: false}, {Changed: true}})
if count != 2 {
t.Fatalf("expected 2 changed rows, got %d", count)
}
}
type fakeTransactionRunner struct {
store *fakeStore
txCalls int
}
func (f *fakeTransactionRunner) InTx(_ context.Context, fn func(store kandangHouseTypeStore) error) error {
f.txCalls++
return fn(f.store)
}
type updateCall struct {
kandangID uint
houseType string
}
type fakeStore struct {
updateCalls []updateCall
changedByID map[uint]bool
updateErrByID map[uint]error
normalizeResult int64
normalizeErr error
}
func (f *fakeStore) UpdateKandangHouseType(_ context.Context, kandangID uint, houseType string) (bool, error) {
f.updateCalls = append(f.updateCalls, updateCall{kandangID: kandangID, houseType: houseType})
if err, exists := f.updateErrByID[kandangID]; exists {
return false, err
}
if changed, exists := f.changedByID[kandangID]; exists {
return changed, nil
}
return true, nil
}
func (f *fakeStore) NormalizeNullHouseType(_ context.Context) (int64, error) {
if f.normalizeErr != nil {
return 0, f.normalizeErr
}
return f.normalizeResult, nil
}
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
if sheetName == "" {
sheetName = "Sheet1"
}
defaultSheet := f.GetSheetName(0)
if defaultSheet != sheetName {
idx, err := f.NewSheet(sheetName)
if err != nil {
t.Fatalf("failed creating sheet: %v", err)
}
f.SetActiveSheet(idx)
_ = f.DeleteSheet(defaultSheet)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed computing header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed computing row cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting row cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "kandang_house_type.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
return path
}
func hasIssue(issues []validationIssue, row int, field string, contains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, contains) {
return true
}
}
return false
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
package main
import (
"context"
"testing"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
)
func TestValidateAdjustmentGatherAgainstAllowedIDsEligible(t *testing.T) {
result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11, 12}, []commonSvc.FifoStockV2GatherRow{
{SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 70},
{SourceTable: "adjustment_stocks", SourceID: 12, AvailableQuantity: 40},
})
if result.Status != "eligible" {
t.Fatalf("expected eligible, got %+v", result)
}
if result.VerifiedQty != 100 {
t.Fatalf("expected verified qty 100, got %v", result.VerifiedQty)
}
}
func TestValidateAdjustmentGatherAgainstAllowedIDsRejectsMixedSource(t *testing.T) {
result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11}, []commonSvc.FifoStockV2GatherRow{
{SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 60},
{SourceTable: "recording_eggs", SourceID: 21, AvailableQuantity: 50},
})
if result.Status != "skipped" {
t.Fatalf("expected skipped, got %+v", result)
}
if result.Reason != "mixed_fifo_source_recording_eggs" {
t.Fatalf("unexpected reason: %+v", result)
}
}
func TestBuildAdjustmentMigrationPlanUsesValidator(t *testing.T) {
opts := &adjustmentCommandOptions{RunID: "egg-adjustment-cutover-test"}
farmID := uint(25)
farmName := "Gudang Farm Jamali"
rows := []adjustmentLegacyEggRow{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 101,
ProductID: 8,
ProductName: "Telur Utuh",
RemainingQty: 120,
CurrentPWQty: 150,
AdjustmentIDs: []uint{1},
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 102,
ProductID: 9,
ProductName: "Telur Putih",
RemainingQty: 20,
CurrentPWQty: 40,
AdjustmentIDs: []uint{2},
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
ProductWarehouseID: 103,
ProductID: 10,
ProductName: "Telur Pecah",
RemainingQty: 10,
CurrentPWQty: 10,
AdjustmentIDs: []uint{3},
},
}
validator := &fakeAdjustmentCandidateValidator{
byProduct: map[string]adjustmentCandidateValidation{
"Telur Utuh": {Status: "eligible", VerifiedQty: 120},
"Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10},
},
}
reportRows, groups := buildAdjustmentMigrationPlan(context.Background(), opts, map[uint]adjustmentLocationTiming{
16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"},
}, rows, validator)
if len(reportRows) != 3 {
t.Fatalf("expected 3 report rows, got %d", len(reportRows))
}
if len(groups) != 1 || len(groups[0].Rows) != 1 {
t.Fatalf("expected only one eligible grouped row, got %+v", groups)
}
if reportRows[0].Status != "eligible" || reportRows[0].VerifiedQty != 120 {
t.Fatalf("unexpected first row: %+v", reportRows[0])
}
if reportRows[1].Reason != "mixed_fifo_source_recording_eggs" {
t.Fatalf("unexpected second row reason: %+v", reportRows[1])
}
if reportRows[2].Reason != "missing_farm_warehouse" {
t.Fatalf("expected missing farm warehouse skip, got %+v", reportRows[2])
}
}
func TestExecuteAdjustmentApplyRevalidatesRowsAndAppliesSubset(t *testing.T) {
opts := &adjustmentCommandOptions{
RunID: "egg-adjustment-cutover-apply",
CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
ActorID: 99,
}
group := adjustmentTransferGroup{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: 25,
FarmWarehouseName: "Gudang Farm Jamali",
Rows: []*adjustmentMigrationReportRow{
{LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 101, ProductID: 8, ProductName: "Telur Utuh", RemainingQty: 120, CurrentPWQty: 150, AdjustmentIDs: []uint{1}, Status: "eligible"},
{LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 102, ProductID: 9, ProductName: "Telur Putih", RemainingQty: 20, CurrentPWQty: 40, AdjustmentIDs: []uint{2}, Status: "eligible"},
},
}
validator := &fakeAdjustmentCandidateValidator{
byProduct: map[string]adjustmentCandidateValidation{
"Telur Utuh": {Status: "eligible", VerifiedQty: 120},
"Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10},
},
}
executor := &fakeAdjustmentSystemTransferExecutor{
createResponses: []*entity.StockTransfer{
{Id: 1001, MovementNumber: "PND-LTI-1001"},
},
}
summary, err := executeAdjustmentApply(context.Background(), executor, validator, opts, []adjustmentTransferGroup{group})
if err != nil {
t.Fatalf("expected no fatal apply error, got %v", err)
}
if summary.GroupsApplied != 1 {
t.Fatalf("expected 1 applied group, got %+v", summary)
}
if summary.RowsApplied != 1 || summary.RowsFailed != 1 {
t.Fatalf("unexpected summary: %+v", summary)
}
if len(executor.createRequests) != 1 {
t.Fatalf("expected 1 create request, got %d", len(executor.createRequests))
}
if len(executor.createRequests[0].Products) != 1 || executor.createRequests[0].Products[0].ProductID != 8 {
t.Fatalf("expected only Telur Utuh to be transferred, got %+v", executor.createRequests[0].Products)
}
}
type fakeAdjustmentCandidateValidator struct {
byProduct map[string]adjustmentCandidateValidation
errByProduct map[string]error
}
func (f *fakeAdjustmentCandidateValidator) ValidateCandidate(ctx context.Context, row adjustmentLegacyEggRow) (adjustmentCandidateValidation, error) {
if err, ok := f.errByProduct[row.ProductName]; ok {
return adjustmentCandidateValidation{}, err
}
if result, ok := f.byProduct[row.ProductName]; ok {
return result, nil
}
return adjustmentCandidateValidation{Status: "eligible", VerifiedQty: row.RemainingQty}, nil
}
type fakeAdjustmentSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer
createErrors []error
deletedTransferIDs []uint
deleteErrors map[uint]error
}
func (f *fakeAdjustmentSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) {
f.createRequests = append(f.createRequests, req)
idx := len(f.createRequests) - 1
if idx < len(f.createErrors) && f.createErrors[idx] != nil {
return nil, f.createErrors[idx]
}
if idx < len(f.createResponses) && f.createResponses[idx] != nil {
return f.createResponses[idx], nil
}
return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil
}
func (f *fakeAdjustmentSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
if f.deleteErrors == nil {
return nil
}
return f.deleteErrors[id]
}
func uintPtr(v uint) *uint { return &v }
func strPtr(v string) *string { return &v }
var _ adjustmentCandidateValidator = (*fakeAdjustmentCandidateValidator)(nil)
var _ adjustmentSystemTransferExecutor = (*fakeAdjustmentSystemTransferExecutor)(nil)
var _ commonSvc.FifoStockV2Lane = fifoStockV2.LaneStockable
@@ -0,0 +1,825 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"sort"
"strings"
"text/tabwriter"
"time"
"github.com/go-playground/validator/v10"
"github.com/sirupsen/logrus"
"gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
pwRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
transferRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
pfkRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gorm.io/gorm"
)
const (
cutoverReasonPrefix = "EGG_FARM_CUTOVER"
outputModeTable = "table"
outputModeJSON = "json"
)
type commandOptions struct {
Apply bool
DryRun bool
RollbackRunID string
LocationID uint
LocationName string
CutoverDate time.Time
CutoverDateRaw string
IncludeOverlap bool
Output string
ActorID uint
RunID string
}
type locationTiming struct {
LocationID uint
LocationName string
FirstKandangDate *time.Time
LastKandangDate *time.Time
FirstFarmDate *time.Time
LastFarmDate *time.Time
Status string
}
type legacyEggStockRow struct {
LocationID uint
LocationName string
SourceWarehouseID uint
SourceWarehouseName string
FarmWarehouseID *uint
FarmWarehouseName *string
ProductWarehouseID uint
ProductID uint
ProductName string
OnHandQty float64
}
type migrationReportRow struct {
RunID string `json:"run_id"`
LocationID uint `json:"location_id"`
LocationName string `json:"location_name"`
SourceWarehouseID uint `json:"source_warehouse_id"`
SourceWarehouseName string `json:"source_warehouse_name"`
FarmWarehouseID *uint `json:"farm_warehouse_id,omitempty"`
FarmWarehouseName *string `json:"farm_warehouse_name,omitempty"`
ProductWarehouseID uint `json:"product_warehouse_id"`
ProductID uint `json:"product_id"`
ProductName string `json:"product_name"`
Qty float64 `json:"qty"`
LocationStatus string `json:"location_status"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
TransferID *uint64 `json:"transfer_id,omitempty"`
MovementNumber *string `json:"movement_number,omitempty"`
}
type applySummary struct {
RowsPlanned int `json:"rows_planned"`
RowsApplied int `json:"rows_applied"`
RowsSkipped int `json:"rows_skipped"`
RowsFailed int `json:"rows_failed"`
GroupsPlanned int `json:"groups_planned"`
GroupsApplied int `json:"groups_applied"`
}
type rollbackDetailRow struct {
RunID string `json:"run_id"`
TransferID uint64 `json:"transfer_id"`
MovementNumber string `json:"movement_number"`
LocationName string `json:"location_name"`
SourceWarehouseName string `json:"source_warehouse_name"`
FarmWarehouseName string `json:"farm_warehouse_name"`
ProductName string `json:"product_name"`
Qty float64 `json:"qty"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
}
type systemTransferExecutor interface {
CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
}
type transferGroup struct {
LocationID uint
LocationName string
SourceWarehouseID uint
SourceWarehouseName string
FarmWarehouseID uint
FarmWarehouseName string
Rows []*migrationReportRow
}
func main() {
opts, err := parseFlags()
if err != nil {
log.Fatalf("invalid flags: %v", err)
}
db := database.Connect(config.DBHost, config.DBName)
ctx := context.Background()
if strings.TrimSpace(opts.RollbackRunID) != "" {
rows, err := loadRollbackDetails(ctx, db, opts.RollbackRunID)
if err != nil {
log.Fatalf("failed to load rollback details: %v", err)
}
if !opts.Apply {
for i := range rows {
rows[i].Status = "eligible"
}
renderRollbackReport(opts.Output, rows)
return
}
if err := executeRollback(ctx, newSystemTransferService(db), rows, opts.ActorID); err != nil {
log.Fatalf("rollback failed: %v", err)
}
renderRollbackReport(opts.Output, rows)
return
}
timings, err := loadLocationTimings(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load location timings: %v", err)
}
legacyRows, err := loadLegacyEggStocks(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load legacy egg stocks: %v", err)
}
reportRows, groups := buildMigrationPlan(opts, timings, legacyRows)
if !opts.Apply {
renderMigrationReport(opts.Output, reportRows, summarizeApply(reportRows, groups, 0))
return
}
summary, err := executeApply(ctx, newSystemTransferService(db), opts, groups)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
finalRows := flattenGroups(groups, reportRows)
summary = summarizeApply(finalRows, groups, summary.GroupsApplied)
renderMigrationReport(opts.Output, finalRows, summary)
}
func parseFlags() (*commandOptions, error) {
var opts commandOptions
flag.BoolVar(&opts.Apply, "apply", false, "Apply migration. If false, run as dry-run")
flag.BoolVar(&opts.DryRun, "dry-run", true, "Run as dry-run")
flag.StringVar(&opts.RollbackRunID, "rollback-run-id", "", "Rollback all transfers created by the provided run id")
flag.UintVar(&opts.LocationID, "location-id", 0, "Filter by location id")
flag.StringVar(&opts.LocationName, "location-name", "", "Filter by exact location name")
flag.StringVar(&opts.CutoverDateRaw, "cutover-date", "", "Cutover date in YYYY-MM-DD format")
flag.BoolVar(&opts.IncludeOverlap, "include-overlap", false, "Include overlap locations in plan/apply")
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
flag.UintVar(&opts.ActorID, "actor-id", 1, "Actor id used for created/deleted transfers")
flag.Parse()
opts.LocationName = strings.TrimSpace(opts.LocationName)
opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID)
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
if opts.Output == "" {
opts.Output = outputModeTable
}
if opts.Output != outputModeTable && opts.Output != outputModeJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
if opts.Apply {
opts.DryRun = false
}
if opts.LocationID > 0 && opts.LocationName != "" {
return nil, errors.New("use either --location-id or --location-name, not both")
}
if opts.RollbackRunID != "" {
if opts.LocationID > 0 || opts.LocationName != "" {
return nil, errors.New("location filters are not supported with --rollback-run-id")
}
if opts.CutoverDateRaw != "" {
return nil, errors.New("--cutover-date is not used with --rollback-run-id")
}
} else if opts.Apply {
if opts.LocationID == 0 && opts.LocationName == "" {
return nil, errors.New("apply mode requires --location-id or --location-name for safety")
}
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
return nil, errors.New("--cutover-date is required in apply mode")
}
}
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
opts.CutoverDate = normalizeDateOnly(time.Now().In(time.FixedZone("Asia/Jakarta", 7*3600)))
} else {
t, err := time.Parse("2006-01-02", opts.CutoverDateRaw)
if err != nil {
return nil, fmt.Errorf("invalid --cutover-date: %w", err)
}
opts.CutoverDate = normalizeDateOnly(t)
}
opts.RunID = buildRunID()
return &opts, nil
}
func newSystemTransferService(db *gorm.DB) systemTransferExecutor {
validate := validator.New()
stockTransferRepo := transferRepo.NewStockTransferRepository(db)
stockTransferDetailRepo := transferRepo.NewStockTransferDetailRepository(db)
stockTransferDeliveryRepo := transferRepo.NewStockTransferDeliveryRepository(db)
stockTransferDeliveryItemRepo := transferRepo.NewStockTransferDeliveryItemRepository(db)
stockLogsRepo := stockLogRepo.NewStockLogRepository(db)
productWarehouseRepo := pwRepo.NewProductWarehouseRepository(db)
warehouseRepository := warehouseRepo.NewWarehouseRepository(db)
projectFlockKandangRepo := pfkRepo.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := pfkRepo.NewProjectFlockPopulationRepository(db)
fifoSvc := service.NewFifoStockV2Service(db, logrus.StandardLogger())
return transferSvc.NewTransferService(
validate,
stockTransferRepo,
stockTransferDetailRepo,
stockTransferDeliveryRepo,
stockTransferDeliveryItemRepo,
stockLogsRepo,
productWarehouseRepo,
nil,
warehouseRepository,
projectFlockKandangRepo,
projectFlockPopulationRepo,
nil,
fifoSvc,
nil,
)
}
func loadLocationTimings(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]locationTiming, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"`
FirstKandangDate *time.Time `gorm:"column:first_kandang_date"`
LastKandangDate *time.Time `gorm:"column:last_kandang_date"`
FirstFarmDate *time.Time `gorm:"column:first_farm_date"`
LastFarmDate *time.Time `gorm:"column:last_farm_date"`
}
query := db.WithContext(ctx).
Table("recording_eggs re").
Select(`
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
`).
Joins("JOIN recordings r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)").
Joins("JOIN project_flocks pf ON pf.id = pk.project_flock_id").
Joins("JOIN locations l ON l.id = pf.location_id").
Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Group("pf.location_id, l.name")
query = applyTimingLocationFilter(query, opts)
var rows []row
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]locationTiming, len(rows))
for _, row := range rows {
status := "KANDANG_ONLY"
if row.FirstFarmDate != nil {
status = "OVERLAP"
if row.LastKandangDate == nil || row.FirstFarmDate.After(normalizeDateOnly(*row.LastKandangDate)) {
status = "CLEAN_CUTOVER"
}
}
result[row.LocationID] = locationTiming{
LocationID: row.LocationID,
LocationName: row.LocationName,
FirstKandangDate: normalizeDatePtr(row.FirstKandangDate),
LastKandangDate: normalizeDatePtr(row.LastKandangDate),
FirstFarmDate: normalizeDatePtr(row.FirstFarmDate),
LastFarmDate: normalizeDatePtr(row.LastFarmDate),
Status: status,
}
}
return result, nil
}
func loadLegacyEggStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]legacyEggStockRow, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"`
SourceWarehouseID uint `gorm:"column:source_warehouse_id"`
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
FarmWarehouseID *uint `gorm:"column:farm_warehouse_id"`
FarmWarehouseName *string `gorm:"column:farm_warehouse_name"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
OnHandQty float64 `gorm:"column:on_hand_qty"`
}
firstFarmSub := db.WithContext(ctx).
Table("warehouses fw").
Select("fw.location_id AS location_id, MIN(fw.id) AS farm_warehouse_id").
Where("fw.deleted_at IS NULL").
Where("fw.type = ?", "LOKASI").
Group("fw.location_id")
query := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
kw.location_id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
`).
Joins("JOIN warehouses kw ON kw.id = pw.warehouse_id AND kw.deleted_at IS NULL").
Joins("JOIN locations l ON l.id = kw.location_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN product_categories pc ON pc.id = p.product_category_id").
Joins("LEFT JOIN (?) ff ON ff.location_id = kw.location_id", firstFarmSub).
Joins("LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id").
Where("kw.type = ?", "KANDANG").
Where(`
EXISTS (
SELECT 1
FROM recording_eggs re
WHERE re.product_warehouse_id = pw.id
)
`).
Where(`
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_type = ?
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = ?
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
`, entity.FlagableTypeProduct, entity.FlagableTypeProduct).
Order("l.name ASC, kw.name ASC, p.name ASC")
query = applyLegacyStockLocationFilter(query, opts)
var rows []row
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
result := make([]legacyEggStockRow, 0, len(rows))
for _, row := range rows {
result = append(result, legacyEggStockRow{
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: row.FarmWarehouseID,
FarmWarehouseName: row.FarmWarehouseName,
ProductWarehouseID: row.ProductWarehouseID,
ProductID: row.ProductID,
ProductName: row.ProductName,
OnHandQty: row.OnHandQty,
})
}
return result, nil
}
func buildMigrationPlan(
opts *commandOptions,
timings map[uint]locationTiming,
rows []legacyEggStockRow,
) ([]migrationReportRow, []transferGroup) {
reportRows := make([]migrationReportRow, 0, len(rows))
groupMap := make(map[string]*transferGroup)
for _, row := range rows {
locationStatus := "UNKNOWN"
if timing, ok := timings[row.LocationID]; ok {
locationStatus = timing.Status
}
report := migrationReportRow{
RunID: opts.RunID,
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: row.FarmWarehouseID,
FarmWarehouseName: row.FarmWarehouseName,
ProductWarehouseID: row.ProductWarehouseID,
ProductID: row.ProductID,
ProductName: row.ProductName,
Qty: row.OnHandQty,
LocationStatus: locationStatus,
Status: "eligible",
}
switch {
case row.FarmWarehouseID == nil || row.FarmWarehouseName == nil:
report.Status = "skipped"
report.Reason = "missing_farm_warehouse"
case row.OnHandQty <= 0:
report.Status = "skipped"
report.Reason = "non_positive_qty"
case locationStatus == "OVERLAP" && !opts.IncludeOverlap:
report.Status = "skipped"
report.Reason = "overlap_location"
}
reportRows = append(reportRows, report)
if report.Status != "eligible" {
continue
}
groupKey := fmt.Sprintf("%d:%d", row.SourceWarehouseID, *row.FarmWarehouseID)
group := groupMap[groupKey]
if group == nil {
group = &transferGroup{
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: *row.FarmWarehouseID,
FarmWarehouseName: derefString(row.FarmWarehouseName),
}
groupMap[groupKey] = group
}
group.Rows = append(group.Rows, &reportRows[len(reportRows)-1])
}
groups := make([]transferGroup, 0, len(groupMap))
for _, group := range groupMap {
sort.Slice(group.Rows, func(i, j int) bool {
return group.Rows[i].ProductName < group.Rows[j].ProductName
})
groups = append(groups, *group)
}
sort.Slice(groups, func(i, j int) bool {
if groups[i].LocationName == groups[j].LocationName {
return groups[i].SourceWarehouseName < groups[j].SourceWarehouseName
}
return groups[i].LocationName < groups[j].LocationName
})
return reportRows, groups
}
func executeApply(
ctx context.Context,
svc systemTransferExecutor,
opts *commandOptions,
groups []transferGroup,
) (applySummary, error) {
summary := applySummary{GroupsPlanned: len(groups)}
for _, group := range groups {
products := make([]transferSvc.SystemTransferProduct, 0, len(group.Rows))
for _, row := range group.Rows {
products = append(products, transferSvc.SystemTransferProduct{
ProductID: row.ProductID,
ProductQty: row.Qty,
})
}
reason := buildCutoverReason(opts.RunID, group.LocationName, opts.CutoverDate)
transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{
TransferReason: reason,
TransferDate: opts.CutoverDate,
SourceWarehouseID: group.SourceWarehouseID,
DestinationWarehouseID: group.FarmWarehouseID,
Products: products,
ActorID: opts.ActorID,
StockLogNotes: reason,
})
if err != nil {
for _, row := range group.Rows {
row.Status = "failed"
row.Reason = err.Error()
summary.RowsFailed++
}
continue
}
summary.GroupsApplied++
for _, row := range group.Rows {
row.Status = "applied"
row.TransferID = &transfer.Id
row.MovementNumber = &transfer.MovementNumber
summary.RowsApplied++
}
}
for _, group := range groups {
summary.RowsPlanned += len(group.Rows)
}
return summary, nil
}
func executeRollback(
ctx context.Context,
svc systemTransferExecutor,
rows []rollbackDetailRow,
actorID uint,
) error {
if actorID == 0 {
return fmt.Errorf("actor id is required for rollback")
}
byTransfer := make(map[uint64][]int)
for idx, row := range rows {
byTransfer[row.TransferID] = append(byTransfer[row.TransferID], idx)
}
transferIDs := make([]uint64, 0, len(byTransfer))
for transferID := range byTransfer {
transferIDs = append(transferIDs, transferID)
}
sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] })
var firstErr error
for _, transferID := range transferIDs {
err := svc.DeleteSystemTransfer(ctx, uint(transferID), actorID)
for _, idx := range byTransfer[transferID] {
if err != nil {
rows[idx].Status = "failed"
rows[idx].Reason = err.Error()
} else {
rows[idx].Status = "rolled_back"
}
}
if err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) {
type row struct {
TransferID uint64 `gorm:"column:transfer_id"`
MovementNumber string `gorm:"column:movement_number"`
LocationName string `gorm:"column:location_name"`
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
FarmWarehouseName string `gorm:"column:farm_warehouse_name"`
ProductName string `gorm:"column:product_name"`
Qty float64 `gorm:"column:qty"`
}
needle := buildRunReasonMatcher(runID)
var dbRows []row
err := db.WithContext(ctx).
Table("stock_transfers st").
Select(`
st.id AS transfer_id,
st.movement_number AS movement_number,
COALESCE(loc.name, '') AS location_name,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS qty
`).
Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id").
Joins("JOIN warehouses wd ON wd.id = st.to_warehouse_id").
Joins("LEFT JOIN locations loc ON loc.id = COALESCE(ws.location_id, wd.location_id)").
Joins("JOIN stock_transfer_details std ON std.stock_transfer_id = st.id AND std.deleted_at IS NULL").
Joins("JOIN products p ON p.id = std.product_id").
Where("st.deleted_at IS NULL").
Where("st.reason LIKE ?", needle).
Order("st.id DESC, std.id ASC").
Scan(&dbRows).Error
if err != nil {
return nil, err
}
rows := make([]rollbackDetailRow, 0, len(dbRows))
for _, row := range dbRows {
rows = append(rows, rollbackDetailRow{
RunID: runID,
TransferID: row.TransferID,
MovementNumber: row.MovementNumber,
LocationName: row.LocationName,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseName: row.FarmWarehouseName,
ProductName: row.ProductName,
Qty: row.Qty,
})
}
return rows, nil
}
func applyTimingLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
if opts == nil {
return db
}
switch {
case opts.LocationID > 0:
return db.Where("pf.location_id = ?", opts.LocationID)
case opts.LocationName != "":
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
default:
return db
}
}
func applyLegacyStockLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
if opts == nil {
return db
}
switch {
case opts.LocationID > 0:
return db.Where("kw.location_id = ?", opts.LocationID)
case opts.LocationName != "":
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
default:
return db
}
}
func buildCutoverReason(runID, locationName string, cutoverDate time.Time) string {
locationName = strings.ReplaceAll(strings.TrimSpace(locationName), "|", "/")
return fmt.Sprintf("%s|run_id=%s|location=%s|cutover_date=%s", cutoverReasonPrefix, runID, locationName, cutoverDate.Format("2006-01-02"))
}
func buildRunReasonMatcher(runID string) string {
return fmt.Sprintf("%s|run_id=%s|%%", cutoverReasonPrefix, strings.TrimSpace(runID))
}
func buildRunID() string {
return fmt.Sprintf("egg-cutover-%s", time.Now().UTC().Format("20060102T150405.000000000Z"))
}
func normalizeDateOnly(value time.Time) time.Time {
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC)
}
func normalizeDatePtr(value *time.Time) *time.Time {
if value == nil {
return nil
}
normalized := normalizeDateOnly(*value)
return &normalized
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func summarizeApply(rows []migrationReportRow, groups []transferGroup, appliedGroups int) applySummary {
summary := applySummary{
GroupsPlanned: len(groups),
GroupsApplied: appliedGroups,
}
for _, row := range rows {
switch row.Status {
case "eligible":
summary.RowsPlanned++
case "applied":
summary.RowsPlanned++
summary.RowsApplied++
case "failed":
summary.RowsPlanned++
summary.RowsFailed++
case "skipped":
summary.RowsSkipped++
}
}
return summary
}
func flattenGroups(groups []transferGroup, fallback []migrationReportRow) []migrationReportRow {
if len(groups) == 0 {
return fallback
}
rows := make([]migrationReportRow, 0, len(fallback))
for _, group := range groups {
for _, row := range group.Rows {
rows = append(rows, *row)
}
}
for _, row := range fallback {
if row.Status == "skipped" {
rows = append(rows, row)
}
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].LocationName == rows[j].LocationName {
if rows[i].SourceWarehouseName == rows[j].SourceWarehouseName {
return rows[i].ProductName < rows[j].ProductName
}
return rows[i].SourceWarehouseName < rows[j].SourceWarehouseName
}
return rows[i].LocationName < rows[j].LocationName
})
return rows
}
func renderMigrationReport(mode string, rows []migrationReportRow, summary applySummary) {
if mode == outputModeJSON {
payload := map[string]any{
"rows": rows,
"summary": summary,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tLOCATION_STATUS\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER")
for _, row := range rows {
transferID := "-"
if row.TransferID != nil {
transferID = fmt.Sprintf("%d", *row.TransferID)
}
movementNumber := "-"
if row.MovementNumber != nil {
movementNumber = *row.MovementNumber
}
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\t%s\t%s\t%s\n",
row.RunID,
row.LocationName,
row.SourceWarehouseName,
derefString(row.FarmWarehouseName),
row.ProductName,
row.Qty,
row.LocationStatus,
row.Status,
row.Reason,
transferID,
movementNumber,
)
}
_ = w.Flush()
fmt.Printf("\nSummary: rows_planned=%d rows_applied=%d rows_skipped=%d rows_failed=%d groups_planned=%d groups_applied=%d\n",
summary.RowsPlanned, summary.RowsApplied, summary.RowsSkipped, summary.RowsFailed, summary.GroupsPlanned, summary.GroupsApplied)
}
func renderRollbackReport(mode string, rows []rollbackDetailRow) {
if mode == outputModeJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]any{"rows": rows})
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "RUN_ID\tTRANSFER_ID\tMOVEMENT_NUMBER\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tSTATUS\tREASON")
for _, row := range rows {
fmt.Fprintf(
w,
"%s\t%d\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\n",
row.RunID,
row.TransferID,
row.MovementNumber,
row.LocationName,
row.SourceWarehouseName,
row.FarmWarehouseName,
row.ProductName,
row.Qty,
row.Status,
row.Reason,
)
}
_ = w.Flush()
}
@@ -0,0 +1,251 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
)
func TestBuildMigrationPlanSkipsOverlapAndGroupsEligibleRows(t *testing.T) {
opts := &commandOptions{
RunID: "egg-cutover-test",
IncludeOverlap: false,
}
timings := map[uint]locationTiming{
16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"},
17: {LocationID: 17, LocationName: "Cijangkar", Status: "OVERLAP"},
}
farmID := uint(25)
farmName := "Gudang Farm Jamali"
rows := []legacyEggStockRow{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 101,
ProductID: 8,
ProductName: "Telur Utuh",
OnHandQty: 120,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 102,
ProductID: 9,
ProductName: "Telur Putih",
OnHandQty: 20,
},
{
LocationID: 17,
LocationName: "Cijangkar",
SourceWarehouseID: 51,
SourceWarehouseName: "Gudang Cijangkar 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 103,
ProductID: 10,
ProductName: "Telur Jumbo",
OnHandQty: 10,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
ProductWarehouseID: 104,
ProductID: 11,
ProductName: "Telur Papacal",
OnHandQty: 50,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 105,
ProductID: 12,
ProductName: "Telur Retak",
OnHandQty: 0,
},
}
reportRows, groups := buildMigrationPlan(opts, timings, rows)
if len(reportRows) != 5 {
t.Fatalf("expected 5 report rows, got %d", len(reportRows))
}
if len(groups) != 1 {
t.Fatalf("expected 1 eligible transfer group, got %d", len(groups))
}
if len(groups[0].Rows) != 2 {
t.Fatalf("expected 2 eligible products in the transfer group, got %d", len(groups[0].Rows))
}
statusByProduct := make(map[string]string, len(reportRows))
reasonByProduct := make(map[string]string, len(reportRows))
for _, row := range reportRows {
statusByProduct[row.ProductName] = row.Status
reasonByProduct[row.ProductName] = row.Reason
}
if statusByProduct["Telur Utuh"] != "eligible" || statusByProduct["Telur Putih"] != "eligible" {
t.Fatalf("expected Jamali egg rows to stay eligible, got statuses %+v", statusByProduct)
}
if reasonByProduct["Telur Jumbo"] != "overlap_location" {
t.Fatalf("expected overlap location skip, got %q", reasonByProduct["Telur Jumbo"])
}
if reasonByProduct["Telur Papacal"] != "missing_farm_warehouse" {
t.Fatalf("expected missing farm warehouse skip, got %q", reasonByProduct["Telur Papacal"])
}
if reasonByProduct["Telur Retak"] != "non_positive_qty" {
t.Fatalf("expected non positive qty skip, got %q", reasonByProduct["Telur Retak"])
}
}
func TestExecuteApplyBuildsTaggedSystemTransfersAndSummaries(t *testing.T) {
opts := &commandOptions{
RunID: "egg-cutover-apply",
CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
ActorID: 99,
}
groups := []transferGroup{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: 25,
FarmWarehouseName: "Gudang Farm Jamali",
Rows: []*migrationReportRow{
{ProductID: 8, ProductName: "Telur Utuh", Qty: 120},
{ProductID: 9, ProductName: "Telur Putih", Qty: 20},
},
},
{
LocationID: 18,
LocationName: "Tamansari",
SourceWarehouseID: 91,
SourceWarehouseName: "Gudang Tamansari 1",
FarmWarehouseID: 31,
FarmWarehouseName: "Gudang Farm Tamansari",
Rows: []*migrationReportRow{
{ProductID: 10, ProductName: "Telur Jumbo", Qty: 10},
},
},
}
executor := &fakeSystemTransferExecutor{
createResponses: []*entity.StockTransfer{
{Id: 1001, MovementNumber: "PND-LTI-1001"},
},
createErrors: []error{
nil,
errors.New("destination warehouse locked"),
},
}
summary, err := executeApply(context.Background(), executor, opts, groups)
if err != nil {
t.Fatalf("expected no fatal apply error, got %v", err)
}
if summary.GroupsPlanned != 2 || summary.GroupsApplied != 1 {
t.Fatalf("unexpected group summary: %+v", summary)
}
if summary.RowsApplied != 2 || summary.RowsFailed != 1 {
t.Fatalf("unexpected row summary: %+v", summary)
}
if len(executor.createRequests) != 2 {
t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests))
}
if !strings.Contains(executor.createRequests[0].TransferReason, "EGG_FARM_CUTOVER|run_id=egg-cutover-apply|location=Jamali|cutover_date=2026-04-07") {
t.Fatalf("unexpected transfer reason: %s", executor.createRequests[0].TransferReason)
}
if executor.createRequests[0].MovementNumber != "" {
t.Fatalf("apply path should let transfer service generate movement number, got %q", executor.createRequests[0].MovementNumber)
}
if groups[0].Rows[0].Status != "applied" || groups[0].Rows[1].Status != "applied" {
t.Fatalf("expected first group rows to be applied, got %+v", groups[0].Rows)
}
if groups[1].Rows[0].Status != "failed" {
t.Fatalf("expected second group row to fail, got %+v", groups[1].Rows[0])
}
if groups[0].Rows[0].TransferID == nil || *groups[0].Rows[0].TransferID != 1001 {
t.Fatalf("expected first row to keep created transfer id, got %+v", groups[0].Rows[0].TransferID)
}
}
func TestExecuteRollbackDeletesTransfersDescendingAndMarksFailures(t *testing.T) {
executor := &fakeSystemTransferExecutor{
deleteErrors: map[uint]error{
101: errors.New("already consumed downstream"),
},
}
rows := []rollbackDetailRow{
{TransferID: 100, ProductName: "Telur Utuh"},
{TransferID: 101, ProductName: "Telur Jumbo"},
{TransferID: 100, ProductName: "Telur Putih"},
}
err := executeRollback(context.Background(), executor, rows, 99)
if err == nil {
t.Fatal("expected rollback to return the first transfer error")
}
if err.Error() != "already consumed downstream" {
t.Fatalf("unexpected rollback error: %v", err)
}
if len(executor.deletedTransferIDs) != 2 {
t.Fatalf("expected 2 delete calls, got %d", len(executor.deletedTransferIDs))
}
if executor.deletedTransferIDs[0] != 101 || executor.deletedTransferIDs[1] != 100 {
t.Fatalf("expected delete order [101 100], got %v", executor.deletedTransferIDs)
}
if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" {
t.Fatalf("expected transfer 100 rows to be rolled back, got %+v", rows)
}
if rows[1].Status != "failed" {
t.Fatalf("expected transfer 101 row to fail, got %+v", rows[1])
}
}
type fakeSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer
createErrors []error
deletedTransferIDs []uint
deleteErrors map[uint]error
}
func (f *fakeSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) {
f.createRequests = append(f.createRequests, req)
idx := len(f.createRequests) - 1
if idx < len(f.createErrors) && f.createErrors[idx] != nil {
return nil, f.createErrors[idx]
}
if idx < len(f.createResponses) && f.createResponses[idx] != nil {
return f.createResponses[idx], nil
}
return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil
}
func (f *fakeSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
if f.deleteErrors == nil {
return nil
}
return f.deleteErrors[id]
}
@@ -0,0 +1,380 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
"gorm.io/gorm"
)
const metricEpsilon = 1e-9
type normalizeOptions struct {
Apply bool
RecordingID uint
ProjectFlockKandangID uint
From *time.Time
To *time.Time
BatchSize int
Limit int
}
type normalizeStats struct {
Processed int
Changed int
Updated int
Skipped int
Failed int
}
type recordingMetricRow struct {
ID uint `gorm:"column:id"`
ProjectFlockKandangID uint `gorm:"column:project_flock_kandangs_id"`
RecordDatetime time.Time `gorm:"column:record_datetime"`
HenHouse *float64 `gorm:"column:hen_house"`
EggMass *float64 `gorm:"column:egg_mass"`
}
func main() {
var (
apply bool
recordingID uint
projectFlockKandangID uint
fromRaw string
toRaw string
batchSize int
limit int
)
flag.BoolVar(&apply, "apply", false, "Apply update. If false, run as dry-run")
flag.UintVar(&recordingID, "recording-id", 0, "Target a single recording ID")
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Filter by project_flock_kandangs_id")
flag.StringVar(&fromRaw, "from", "", "Lower bound record_datetime (RFC3339 / YYYY-MM-DD)")
flag.StringVar(&toRaw, "to", "", "Upper bound record_datetime (RFC3339 / YYYY-MM-DD)")
flag.IntVar(&batchSize, "batch-size", 200, "Batch size when scanning recordings")
flag.IntVar(&limit, "limit", 0, "Max recordings to process (0 = no limit)")
flag.Parse()
if batchSize <= 0 {
log.Fatal("--batch-size must be > 0")
}
if limit < 0 {
log.Fatal("--limit cannot be negative")
}
from, err := parseTimeBound(strings.TrimSpace(fromRaw), false)
if err != nil {
log.Fatalf("invalid --from: %v", err)
}
to, err := parseTimeBound(strings.TrimSpace(toRaw), true)
if err != nil {
log.Fatalf("invalid --to: %v", err)
}
if from != nil && to != nil && to.Before(*from) {
log.Fatal("--to cannot be before --from")
}
opts := normalizeOptions{
Apply: apply,
RecordingID: recordingID,
ProjectFlockKandangID: projectFlockKandangID,
From: from,
To: to,
BatchSize: batchSize,
Limit: limit,
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
repo := recordingRepo.NewRecordingRepository(db)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("Filter recording_id: %s\n", displayUint(opts.RecordingID))
fmt.Printf("Filter project_flock_kandangs_id: %s\n", displayUint(opts.ProjectFlockKandangID))
fmt.Printf("Filter from: %s\n", displayTime(opts.From))
fmt.Printf("Filter to: %s\n", displayTime(opts.To))
fmt.Printf("Batch size: %d\n", opts.BatchSize)
fmt.Printf("Limit: %d\n\n", opts.Limit)
stats, err := normalizeRecordings(ctx, db, repo, opts)
if err != nil {
log.Fatalf("normalize failed: %v", err)
}
fmt.Println()
fmt.Printf(
"Summary: processed=%d changed=%d updated=%d skipped=%d failed=%d\n",
stats.Processed,
stats.Changed,
stats.Updated,
stats.Skipped,
stats.Failed,
)
if stats.Failed > 0 {
os.Exit(1)
}
}
func normalizeRecordings(
ctx context.Context,
db *gorm.DB,
repo recordingRepo.RecordingRepository,
opts normalizeOptions,
) (normalizeStats, error) {
stats := normalizeStats{}
lastID := uint(0)
initialChickCache := make(map[uint]float64)
for {
batchLimit := opts.BatchSize
if opts.Limit > 0 {
remaining := opts.Limit - stats.Processed
if remaining <= 0 {
break
}
if remaining < batchLimit {
batchLimit = remaining
}
}
rows, err := loadRecordingBatch(ctx, db, opts, lastID, batchLimit)
if err != nil {
return stats, err
}
if len(rows) == 0 {
break
}
for _, row := range rows {
stats.Processed++
lastID = row.ID
initialChick, ok := initialChickCache[row.ProjectFlockKandangID]
if !ok {
initialChick, err = repo.GetTotalChickinByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID)
if err != nil {
fmt.Printf("FAIL rec=%d error=getTotalChickinByProjectFlockKandang: %v\n", row.ID, err)
stats.Failed++
continue
}
initialChickCache[row.ProjectFlockKandangID] = initialChick
}
_, totalEggWeightGrams, err := repo.GetEggSummaryByRecording(db.WithContext(ctx), row.ID)
if err != nil {
fmt.Printf("FAIL rec=%d error=getEggSummaryByRecording: %v\n", row.ID, err)
stats.Failed++
continue
}
cumulativeEggQty, err := repo.GetCumulativeEggQtyByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID, row.RecordDatetime)
if err != nil {
fmt.Printf("FAIL rec=%d error=getCumulativeEggQtyByProjectFlockKandang: %v\n", row.ID, err)
stats.Failed++
continue
}
newHenHouse, newEggMass := computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams)
henHouseChanged := metricChanged(row.HenHouse, newHenHouse)
eggMassChanged := metricChanged(row.EggMass, newEggMass)
if !henHouseChanged && !eggMassChanged {
stats.Skipped++
continue
}
stats.Changed++
fmt.Printf(
"PLAN rec=%d pfk=%d at=%s hen_house:%s->%s egg_mass:%s->%s\n",
row.ID,
row.ProjectFlockKandangID,
row.RecordDatetime.UTC().Format(time.RFC3339),
displayFloat(row.HenHouse),
displayFloat(newHenHouse),
displayFloat(row.EggMass),
displayFloat(newEggMass),
)
if !opts.Apply {
continue
}
if err := updateRecordingMetrics(ctx, db, row.ID, newHenHouse, newEggMass); err != nil {
fmt.Printf("FAIL rec=%d error=updateRecordingMetrics: %v\n", row.ID, err)
stats.Failed++
continue
}
fmt.Printf(
"DONE rec=%d hen_house=%s egg_mass=%s\n",
row.ID,
displayFloat(newHenHouse),
displayFloat(newEggMass),
)
stats.Updated++
}
if opts.RecordingID > 0 {
break
}
}
return stats, nil
}
func loadRecordingBatch(
ctx context.Context,
db *gorm.DB,
opts normalizeOptions,
lastID uint,
limit int,
) ([]recordingMetricRow, error) {
query := db.WithContext(ctx).
Table("recordings").
Select("id, project_flock_kandangs_id, record_datetime, hen_house, egg_mass").
Where("recordings.deleted_at IS NULL")
if opts.RecordingID > 0 {
query = query.Where("recordings.id = ?", opts.RecordingID)
}
if opts.ProjectFlockKandangID > 0 {
query = query.Where("recordings.project_flock_kandangs_id = ?", opts.ProjectFlockKandangID)
}
if opts.From != nil {
query = query.Where("recordings.record_datetime >= ?", *opts.From)
}
if opts.To != nil {
query = query.Where("recordings.record_datetime <= ?", *opts.To)
}
if opts.RecordingID == 0 && lastID > 0 {
query = query.Where("recordings.id > ?", lastID)
}
var rows []recordingMetricRow
err := query.
Order("recordings.id ASC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams float64) (*float64, *float64) {
var henHouse *float64
if initialChick > 0 && cumulativeEggQty >= 0 {
value := cumulativeEggQty / initialChick
henHouse = &value
}
var eggMass *float64
if initialChick > 0 && totalEggWeightGrams > 0 {
value := totalEggWeightGrams / initialChick
eggMass = &value
}
return henHouse, eggMass
}
func updateRecordingMetrics(ctx context.Context, db *gorm.DB, recordingID uint, henHouse, eggMass *float64) error {
updates := map[string]any{}
if henHouse == nil {
updates["hen_house"] = gorm.Expr("NULL")
} else {
updates["hen_house"] = *henHouse
}
if eggMass == nil {
updates["egg_mass"] = gorm.Expr("NULL")
} else {
updates["egg_mass"] = *eggMass
}
return db.WithContext(ctx).
Table("recordings").
Where("id = ?", recordingID).
Updates(updates).Error
}
func metricChanged(oldValue, newValue *float64) bool {
if oldValue == nil && newValue == nil {
return false
}
if oldValue == nil || newValue == nil {
return true
}
return !nearlyEqual(*oldValue, *newValue)
}
func nearlyEqual(a, b float64) bool {
scale := math.Max(1, math.Max(math.Abs(a), math.Abs(b)))
return math.Abs(a-b) <= metricEpsilon*scale
}
func parseTimeBound(raw string, isUpper bool) (*time.Time, error) {
if raw == "" {
return nil, nil
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, raw)
if err != nil {
continue
}
if layout == "2006-01-02" {
if isUpper {
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
return &endOfDay, nil
}
startOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC)
return &startOfDay, nil
}
t := parsed.UTC()
return &t, nil
}
return nil, fmt.Errorf("unsupported format %q", raw)
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func displayFloat(v *float64) string {
if v == nil {
return "NULL"
}
return fmt.Sprintf("%.6f", *v)
}
func displayTime(v *time.Time) string {
if v == nil {
return "<nil>"
}
return v.UTC().Format(time.RFC3339)
}
func displayUint(v uint) string {
if v == 0 {
return "<all>"
}
return fmt.Sprintf("%d", v)
}
+333
View File
@@ -0,0 +1,333 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
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"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type adjustmentRow struct {
ID uint `gorm:"column:id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
FunctionCode string `gorm:"column:function_code"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type routeResolution struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
SourceTable string `gorm:"column:source_table"`
LegacyTypeKey string `gorm:"column:legacy_type_key"`
}
func main() {
var (
idsRaw string
apply bool
asOfCreatedAt bool
compensateMissingAlloc bool
)
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.BoolVar(&asOfCreatedAt, "as-of-created-at", true, "Use adjustment created_at as reflow AsOf boundary")
flag.BoolVar(&compensateMissingAlloc, "compensate-missing-alloc", true, "When active allocations are missing and usage_qty > 0, temporarily add back usage_qty before reflow")
flag.Parse()
ids, err := parseIDs(idsRaw)
if err != nil {
log.Fatalf("invalid --ids: %v", err)
}
if len(ids) == 0 {
log.Fatal("--ids is required")
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
adjustments, err := loadAdjustments(ctx, db, ids)
if err != nil {
log.Fatalf("failed to load adjustments: %v", err)
}
if len(adjustments) == 0 {
log.Fatal("no adjustments found for provided IDs")
}
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].ID < adjustments[j].ID
})
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
success := 0
failed := 0
skipped := 0
for _, adj := range adjustments {
if strings.TrimSpace(adj.FunctionCode) == "" {
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
skipped++
continue
}
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
if err != nil {
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
failed++
continue
}
if route.Lane != "USABLE" {
fmt.Printf("SKIP adj=%d reason=lane=%s (not USABLE)\n", adj.ID, route.Lane)
skipped++
continue
}
desiredQty := adj.UsageQty + adj.PendingQty
desiredQtySource := "usage+pending"
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
desiredQty = adj.StockLogDecrease
desiredQtySource = "stock_log.decrease"
}
if desiredQty <= 0 {
fmt.Printf(
"SKIP adj=%d reason=no usable qty (usage=%.3f pending=%.3f stock_log.decrease=%.3f)\n",
adj.ID,
adj.UsageQty,
adj.PendingQty,
adj.StockLogDecrease,
)
skipped++
continue
}
activeAllocationCount, err := countActiveAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count allocations: %v\n", adj.ID, err)
failed++
continue
}
compensateQty := adj.UsageQty
if compensateQty <= 0 && desiredQtySource == "stock_log.decrease" {
compensateQty = adj.StockLogDecrease
}
shouldCompensate := compensateMissingAlloc && activeAllocationCount == 0 && compensateQty > 0
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
IdempotencyKey: fmt.Sprintf("manual-adjustment-reflow-%d-%d", adj.ID, time.Now().UnixNano()),
}
if asOfCreatedAt {
asOf := adj.CreatedAt
reflowReq.AsOf = &asOf
}
fmt.Printf(
"PLAN adj=%d pw=%d product=%d function=%s group=%s desired=%.3f source=%s active_alloc=%d compensate=%t\n",
adj.ID,
adj.ProductWarehouseID,
adj.ProductID,
route.FunctionCode,
route.FlagGroupCode,
desiredQty,
desiredQtySource,
activeAllocationCount,
shouldCompensate,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if shouldCompensate {
if err := tx.Table("product_warehouses").
Where("id = ?", adj.ProductWarehouseID).
Update("qty", gorm.Expr("COALESCE(qty,0) + ?", compensateQty)).Error; err != nil {
return fmt.Errorf("compensate product_warehouse qty: %w", err)
}
}
reflowReq.Tx = tx
res, err := fifoStockV2Svc.Reflow(ctx, reflowReq)
if err != nil {
return err
}
fmt.Printf(
"DONE adj=%d rollback=%.3f allocate=%.3f pending=%.3f\n",
adj.ID,
res.Rollback.ReleasedQty,
res.Allocate.AllocatedQty,
res.Allocate.PendingQty,
)
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
success++
}
fmt.Println()
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
if failed > 0 {
os.Exit(1)
}
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func parseIDs(raw string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
ids := make([]uint, 0, len(parts))
seen := map[uint]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid id %q", part)
}
if value == 0 {
return nil, fmt.Errorf("id must be > 0: %q", part)
}
id := uint(value)
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
var rows []adjustmentRow
err := db.WithContext(ctx).
Table("adjustment_stocks a").
Select(`
a.id,
a.product_warehouse_id,
pw.product_id,
a.function_code,
COALESCE(a.usage_qty, 0) AS usage_qty,
COALESCE(a.pending_qty, 0) AS pending_qty,
COALESCE((
SELECT sl.increase
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_increase,
COALESCE((
SELECT sl.decrease
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_decrease,
a.created_at
`).
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
Where("a.id IN ?", ids).
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
var rows []routeResolution
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code, rr.lane, rr.function_code, rr.source_table, rr.legacy_type_key").
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.function_code = ?", functionCode).
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, entity.FlagableTypeProduct, productID).
Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
Order("rr.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
}
selected := rows[0]
for _, row := range rows {
if row.Lane != selected.Lane {
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
}
}
selected.FunctionCode = functionCode
return &selected, nil
}
func countActiveAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
+648
View File
@@ -0,0 +1,648 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math"
"os"
"sort"
"strings"
"time"
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"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type productWarehouseScopeRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
WarehouseID uint `gorm:"column:warehouse_id"`
ProjectFlockKandangID *uint `gorm:"column:project_flock_kandang_id"`
}
type reflowTarget struct {
ProductWarehouseID uint
ProductID uint
WarehouseID uint
ProjectFlockKandangID *uint
FlagGroupCode string
}
func main() {
var (
projectFlockKandangID uint
apply bool
asOfRaw string
includeShared bool
)
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Project flock kandang ID (required)")
flag.BoolVar(&apply, "apply", false, "Apply reflow. If false, run as dry-run")
flag.StringVar(&asOfRaw, "as-of", "", "Optional AsOf boundary. Format: RFC3339 or YYYY-MM-DD")
flag.BoolVar(&includeShared, "include-shared", true, "Include product warehouses referenced by transactions in this PFK scope (including shared/non-bound product warehouses)")
flag.Parse()
if projectFlockKandangID == 0 {
log.Fatal("--project-flock-kandang-id is required")
}
asOf, err := parseAsOf(asOfRaw)
if err != nil {
log.Fatalf("invalid --as-of: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
exists, err := projectFlockKandangExists(ctx, db, projectFlockKandangID)
if err != nil {
log.Fatalf("failed to check project flock kandang: %v", err)
}
if !exists {
log.Fatalf("project_flock_kandang_id %d not found", projectFlockKandangID)
}
scopedPWs, err := loadScopedProductWarehouses(ctx, db, projectFlockKandangID, includeShared)
if err != nil {
log.Fatalf("failed to load scoped product warehouses: %v", err)
}
if len(scopedPWs) == 0 {
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Scope: project_flock_kandang_id=%d\n", projectFlockKandangID)
fmt.Println("No product warehouse found in scope")
return
}
targets := make([]reflowTarget, 0, len(scopedPWs))
skippedPW := 0
failedResolve := 0
for _, pw := range scopedPWs {
flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, db, pw.ProductWarehouseID)
if err != nil {
fmt.Printf("FAIL pw=%d error=resolve flag groups: %v\n", pw.ProductWarehouseID, err)
failedResolve++
continue
}
if len(flagGroups) == 0 {
fmt.Printf("SKIP pw=%d reason=no active fifo v2 route by product flag\n", pw.ProductWarehouseID)
skippedPW++
continue
}
for _, group := range flagGroups {
targets = append(targets, reflowTarget{
ProductWarehouseID: pw.ProductWarehouseID,
ProductID: pw.ProductID,
WarehouseID: pw.WarehouseID,
ProjectFlockKandangID: pw.ProjectFlockKandangID,
FlagGroupCode: group,
})
}
}
sort.Slice(targets, func(i, j int) bool {
if targets[i].ProductWarehouseID == targets[j].ProductWarehouseID {
return targets[i].FlagGroupCode < targets[j].FlagGroupCode
}
return targets[i].ProductWarehouseID < targets[j].ProductWarehouseID
})
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Scope: project_flock_kandang_id=%d include_shared=%t\n", projectFlockKandangID, includeShared)
if asOf != nil {
fmt.Printf("AsOf: %s\n", asOf.UTC().Format(time.RFC3339))
} else {
fmt.Println("AsOf: <nil> (full timeline)")
}
fmt.Printf("Product warehouses in scope: %d\n", len(scopedPWs))
fmt.Printf("Planned reflow targets: %d\n\n", len(targets))
for _, target := range targets {
fmt.Printf(
"PLAN pw=%d product=%d warehouse=%d pw_pfk=%s flag_group=%s\n",
target.ProductWarehouseID,
target.ProductID,
target.WarehouseID,
displayOptionalUint(target.ProjectFlockKandangID),
target.FlagGroupCode,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=0 failed_apply=0\n", len(targets), skippedPW, failedResolve)
if failedResolve > 0 {
os.Exit(1)
}
return
}
successApply := 0
failedApply := 0
for idx, target := range targets {
req := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: target.FlagGroupCode,
ProductWarehouseID: target.ProductWarehouseID,
AsOf: asOf,
IdempotencyKey: fmt.Sprintf(
"manual-pfk-reflow-%d-%d-%s-%d-%d",
projectFlockKandangID,
target.ProductWarehouseID,
strings.ToUpper(strings.TrimSpace(target.FlagGroupCode)),
time.Now().UnixNano(),
idx,
),
}
res, err := fifoStockV2Svc.Reflow(ctx, req)
if err != nil {
fmt.Printf("FAIL pw=%d flag_group=%s error=%v\n", target.ProductWarehouseID, target.FlagGroupCode, err)
failedApply++
continue
}
fmt.Printf(
"DONE pw=%d flag_group=%s rollback=%.3f allocate=%.3f pending=%.3f processed_usable=%d\n",
target.ProductWarehouseID,
target.FlagGroupCode,
res.Rollback.ReleasedQty,
res.Allocate.AllocatedQty,
res.Allocate.PendingQty,
res.ProcessedUsables,
)
successApply++
}
orphanPopulationRows := int64(0)
syncedPopulationQtyRows := int64(0)
syncedPopulationUsedRows := int64(0)
traceReleasedRows := int64(0)
traceInsertedRows := int64(0)
if rowsOrphan, rowsQty, rowsUsed, err := resyncProjectFlockPopulation(ctx, db, projectFlockKandangID); err != nil {
fmt.Printf("FAIL population_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err)
failedApply++
} else {
orphanPopulationRows = rowsOrphan
syncedPopulationQtyRows = rowsQty
syncedPopulationUsedRows = rowsUsed
fmt.Printf(
"SYNC project_flock_populations orphan_marked=%d qty_synced=%d used_synced=%d\n",
orphanPopulationRows,
syncedPopulationQtyRows,
syncedPopulationUsedRows,
)
}
if released, inserted, err := resyncChickinTraceByProjectFlockKandang(ctx, db, fifoStockV2Svc, projectFlockKandangID); err != nil {
fmt.Printf("FAIL chickin_trace_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err)
failedApply++
} else {
traceReleasedRows = released
traceInsertedRows = inserted
fmt.Printf(
"SYNC chickin_trace released=%d inserted=%d\n",
traceReleasedRows,
traceInsertedRows,
)
}
fmt.Println()
fmt.Printf(
"Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d population_orphan=%d population_qty_synced=%d population_used_synced=%d trace_released=%d trace_inserted=%d\n",
len(targets),
skippedPW,
failedResolve,
successApply,
failedApply,
orphanPopulationRows,
syncedPopulationQtyRows,
syncedPopulationUsedRows,
traceReleasedRows,
traceInsertedRows,
)
if failedResolve > 0 || failedApply > 0 {
os.Exit(1)
}
}
func parseAsOf(raw string) (*time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, raw)
if err != nil {
continue
}
if layout == "2006-01-02" {
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
return &endOfDay, nil
}
asOf := parsed.UTC()
return &asOf, nil
}
return nil, fmt.Errorf("unsupported format %q", raw)
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func displayOptionalUint(v *uint) string {
if v == nil {
return "NULL"
}
return fmt.Sprintf("%d", *v)
}
func projectFlockKandangExists(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (bool, error) {
var count int64
err := db.WithContext(ctx).
Table("project_flock_kandangs").
Where("id = ?", projectFlockKandangID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func loadScopedProductWarehouses(ctx context.Context, db *gorm.DB, projectFlockKandangID uint, includeShared bool) ([]productWarehouseScopeRow, error) {
if !includeShared {
var rows []productWarehouseScopeRow
err := db.WithContext(ctx).
Table("product_warehouses").
Select("id AS product_warehouse_id, product_id, warehouse_id, project_flock_kandang_id").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Order("id ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
query := `
WITH scoped_pw AS (
SELECT pw.id AS product_warehouse_id
FROM product_warehouses pw
WHERE pw.project_flock_kandang_id = ?
UNION
SELECT pc.product_warehouse_id
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NULL
UNION
SELECT rs.product_warehouse_id
FROM recordings r
JOIN recording_stocks rs ON rs.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
UNION
SELECT rd.product_warehouse_id
FROM recordings r
JOIN recording_depletions rd ON rd.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
UNION
SELECT rd.source_product_warehouse_id
FROM recordings r
JOIN recording_depletions rd ON rd.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
AND rd.source_product_warehouse_id IS NOT NULL
UNION
SELECT re.product_warehouse_id
FROM recordings r
JOIN recording_eggs re ON re.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
UNION
SELECT lts.product_warehouse_id
FROM laying_transfer_sources lts
WHERE lts.source_project_flock_kandang_id = ?
AND lts.deleted_at IS NULL
AND lts.product_warehouse_id IS NOT NULL
UNION
SELECT ltt.product_warehouse_id
FROM laying_transfer_targets ltt
WHERE ltt.target_project_flock_kandang_id = ?
AND ltt.deleted_at IS NULL
AND ltt.product_warehouse_id IS NOT NULL
UNION
SELECT pi.product_warehouse_id
FROM purchase_items pi
WHERE pi.project_flock_kandang_id = ?
AND pi.product_warehouse_id IS NOT NULL
)
SELECT DISTINCT
pw.id AS product_warehouse_id,
pw.product_id,
pw.warehouse_id,
pw.project_flock_kandang_id
FROM scoped_pw s
JOIN product_warehouses pw ON pw.id = s.product_warehouse_id
ORDER BY pw.id ASC
`
var rows []productWarehouseScopeRow
err := db.WithContext(ctx).
Raw(
query,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
).
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func resolveFlagGroupsByProductWarehouse(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]string, error) {
var groups []string
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("DISTINCT rr.flag_group_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where(`
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 = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, productWarehouseID, entity.FlagableTypeProduct).
Order("rr.flag_group_code ASC").
Scan(&groups).Error
if err != nil {
return nil, err
}
return groups, nil
}
func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, int64, int64, error) {
if projectFlockKandangID == 0 {
return 0, 0, 0, nil
}
orphanResult := db.WithContext(ctx).Exec(`
UPDATE project_flock_populations pfp
SET deleted_at = NOW(),
updated_at = NOW()
FROM project_chickins pc
WHERE pfp.project_chickin_id = pc.id
AND pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NOT NULL
AND pfp.deleted_at IS NULL
`, projectFlockKandangID)
if orphanResult.Error != nil {
return 0, 0, 0, orphanResult.Error
}
qtyResult := db.WithContext(ctx).Exec(`
UPDATE project_flock_populations p
SET total_qty = GREATEST(COALESCE(pc.usage_qty, 0), 0),
updated_at = NOW()
FROM project_chickins pc
WHERE p.project_chickin_id = pc.id
AND pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NULL
AND p.deleted_at IS NULL
`, projectFlockKandangID)
if qtyResult.Error != nil {
return 0, 0, 0, qtyResult.Error
}
usedResult := db.WithContext(ctx).Exec(`
WITH scoped AS (
SELECT pfp.id, pfp.total_qty
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NULL
AND pfp.deleted_at IS NULL
),
alloc AS (
SELECT sa.stockable_id, SUM(sa.qty) AS used_qty
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
GROUP BY sa.stockable_id
)
UPDATE project_flock_populations p
SET total_used_qty = LEAST(COALESCE(a.used_qty, 0), GREATEST(s.total_qty, 0)),
updated_at = NOW()
FROM scoped s
LEFT JOIN alloc a ON a.stockable_id = s.id
WHERE p.id = s.id
`, projectFlockKandangID)
if usedResult.Error != nil {
return 0, 0, 0, usedResult.Error
}
return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil
}
func resyncChickinTraceByProjectFlockKandang(
ctx context.Context,
db *gorm.DB,
fifoStockV2Svc commonSvc.FifoStockV2Service,
projectFlockKandangID uint,
) (int64, int64, error) {
if projectFlockKandangID == 0 {
return 0, 0, nil
}
var productWarehouseIDs []uint
if err := db.WithContext(ctx).
Table("project_chickins").
Distinct("product_warehouse_id").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Where("deleted_at IS NULL").
Order("product_warehouse_id ASC").
Pluck("product_warehouse_id", &productWarehouseIDs).Error; err != nil {
return 0, 0, err
}
if len(productWarehouseIDs) == 0 {
return 0, 0, nil
}
totalReleased := int64(0)
totalInserted := int64(0)
for _, productWarehouseID := range productWarehouseIDs {
var releasedRows int64
var insertedRows int64
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return err
}
if len(flagGroups) == 0 {
return nil
}
flagGroupCode := strings.TrimSpace(flagGroups[0])
if flagGroupCode == "" {
return nil
}
released := tx.WithContext(ctx).
Table("stock_allocations").
Where("product_warehouse_id = ?", productWarehouseID).
Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin).
Where("status = ?", entity.StockAllocationStatusActive).
Updates(map[string]any{
"status": entity.StockAllocationStatusReleased,
"released_at": time.Now(),
"updated_at": time.Now(),
"note": "chickin_trace_reflow_reset",
})
if released.Error != nil {
return released.Error
}
releasedRows = released.RowsAffected
type chickinRow struct {
ID uint `gorm:"column:id"`
UsageQty float64 `gorm:"column:usage_qty"`
ChickIn time.Time `gorm:"column:chick_in_date"`
}
chickins := make([]chickinRow, 0)
if err := tx.WithContext(ctx).
Table("project_chickins").
Select("id, usage_qty, chick_in_date").
Where("product_warehouse_id = ?", productWarehouseID).
Where("deleted_at IS NULL").
Where("usage_qty > 0").
Order("chick_in_date ASC, id ASC").
Scan(&chickins).Error; err != nil {
return err
}
if len(chickins) == 0 {
return nil
}
gatherRows, err := fifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: "STOCKABLE",
AllocationPurpose: entity.StockAllocationPurposeTraceChickin,
IgnoreSourceUsed: true,
ProductWarehouseID: productWarehouseID,
Limit: 50000,
Tx: tx,
})
if err != nil {
return err
}
if len(gatherRows) == 0 {
return nil
}
type lotKey struct {
StockableType string
StockableID uint
}
remainingByLot := make(map[lotKey]float64, len(gatherRows))
for _, row := range gatherRows {
key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID}
remainingByLot[key] = row.AvailableQuantity
}
now := time.Now()
lotIndex := 0
for _, chickinRow := range chickins {
remaining := chickinRow.UsageQty
for remaining > 1e-6 && lotIndex < len(gatherRows) {
lot := gatherRows[lotIndex]
key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID}
available := remainingByLot[key]
if available <= 1e-6 {
lotIndex++
continue
}
portion := math.Min(remaining, available)
if portion <= 1e-6 {
lotIndex++
continue
}
insert := map[string]any{
"product_warehouse_id": productWarehouseID,
"stockable_type": lot.Ref.LegacyTypeKey,
"stockable_id": lot.Ref.ID,
"usable_type": fifo.UsableKeyProjectChickin.String(),
"usable_id": chickinRow.ID,
"qty": portion,
"status": entity.StockAllocationStatusActive,
"allocation_purpose": entity.StockAllocationPurposeTraceChickin,
"engine_version": "v2",
"flag_group_code": flagGroupCode,
"function_code": "CHICKIN_TRACE",
"created_at": now,
"updated_at": now,
}
if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil {
return err
}
insertedRows++
remaining -= portion
remainingByLot[key] = available - portion
}
}
return nil
})
if err != nil {
return totalReleased, totalInserted, err
}
totalReleased += releasedRows
totalInserted += insertedRows
}
return totalReleased, totalInserted, nil
}
@@ -0,0 +1,282 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
const qtyEpsilon = 1e-6
const (
levelAll = 1
levelByProductName = 2
levelByProductWarehouse = 3
)
type reflowRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
CurrentQty float64 `gorm:"column:current_qty"`
SumTotalQty float64 `gorm:"column:sum_total_qty"`
SumAllocatedQty float64 `gorm:"column:sum_allocated_qty"`
ComputedQty float64 `gorm:"column:computed_qty"`
}
func main() {
var (
apply bool
level int
productName string
productWarehouseID uint
)
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.IntVar(&level, "level", levelAll, "CLI level: 1=all product_warehouse scope, 2=product name scope, 3=product_warehouse_id scope")
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
flag.Parse()
productName = strings.TrimSpace(productName)
if err := validateFlags(level, productName, productWarehouseID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
rows, err := loadReflowRows(ctx, db, level, productName, productWarehouseID)
if err != nil {
log.Fatalf("failed to calculate reflow qty: %v", err)
}
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
if productName != "" {
fmt.Printf("Filter product_name: %s\n", productName)
}
if productWarehouseID > 0 {
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
}
fmt.Printf("Targets found: %d\n\n", len(rows))
if len(rows) == 0 {
fmt.Println("No product warehouse found from purchase_items scope")
return
}
negativePlan := 0
for _, row := range rows {
if row.ComputedQty < 0 {
negativePlan++
}
fmt.Printf(
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f total_qty=%.3f allocated_qty=%.3f computed_qty=%.3f delta=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.SumTotalQty,
row.SumAllocatedQty,
row.ComputedQty,
row.ComputedQty-row.CurrentQty,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0 negative_plan=%d\n", len(rows), negativePlan)
return
}
updated := 0
skipped := 0
negativeUpdated := 0
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, row := range rows {
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
fmt.Printf(
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
row.ProductWarehouseID,
row.CurrentQty,
row.ComputedQty,
)
skipped++
continue
}
if err := tx.Table("product_warehouses").
Where("id = ?", row.ProductWarehouseID).
Update("qty", row.ComputedQty).Error; err != nil {
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
}
if row.ComputedQty < 0 {
negativeUpdated++
}
fmt.Printf(
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
)
updated++
}
return nil
})
if err != nil {
fmt.Println()
fmt.Printf(
"Summary: planned=%d updated=%d skipped=%d failed=1 negative_plan=%d negative_updated=%d\n",
len(rows),
updated,
skipped,
negativePlan,
negativeUpdated,
)
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Printf(
"Summary: planned=%d updated=%d skipped=%d failed=0 negative_plan=%d negative_updated=%d\n",
len(rows),
updated,
skipped,
negativePlan,
negativeUpdated,
)
}
func validateFlags(level int, productName string, productWarehouseID uint) error {
switch level {
case levelAll:
if productName != "" {
return errors.New("--product-name cannot be used on level 1")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 1")
}
case levelByProductName:
if productName == "" {
return errors.New("--product-name is required on level 2")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 2")
}
case levelByProductWarehouse:
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required on level 3")
}
if productName != "" {
return errors.New("--product-name cannot be used on level 3")
}
default:
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
}
return nil
}
func loadReflowRows(
ctx context.Context,
db *gorm.DB,
level int,
productName string,
productWarehouseID uint,
) ([]reflowRow, error) {
allocSub := db.WithContext(ctx).
Table("stock_allocations sa").
Select(`
sa.stockable_id,
COALESCE(SUM(sa.qty), 0) AS used_qty
`).
Where("sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.deleted_at IS NULL").
Group("sa.stockable_id")
calcSub := db.WithContext(ctx).
Table("purchase_items pi").
Select(`
pi.product_warehouse_id,
COALESCE(SUM(pi.total_qty), 0) AS sum_total_qty,
COALESCE(SUM(COALESCE(alloc.used_qty, 0)), 0) AS sum_allocated_qty,
COALESCE(SUM(COALESCE(pi.total_qty, 0) - COALESCE(alloc.used_qty, 0)), 0) AS computed_qty
`).
Joins("LEFT JOIN (?) alloc ON alloc.stockable_id = pi.id", allocSub).
Where("pi.product_warehouse_id IS NOT NULL").
Group("pi.product_warehouse_id")
query := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
calc.sum_total_qty,
calc.sum_allocated_qty,
calc.computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN (?) calc ON calc.product_warehouse_id = pw.id", calcSub).
Order("pw.id ASC")
switch level {
case levelByProductName:
query = query.Where("LOWER(p.name) = LOWER(?)", productName)
case levelByProductWarehouse:
query = query.Where("pw.id = ?", productWarehouseID)
}
rows := make([]reflowRow, 0)
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func levelLabel(level int) string {
switch level {
case levelAll:
return "all product_warehouse from purchase_items"
case levelByProductName:
return "specific product name"
case levelByProductWarehouse:
return "specific product_warehouse_id"
default:
return "unknown"
}
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
type options struct {
FilePath string
Apply bool
}
func main() {
var opts options
flag.StringVar(&opts.FilePath, "file", "", "Path to .sql file (required)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply SQL to database. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
sqlContent, err := readSQLFile(opts.FilePath)
if err != nil {
log.Fatalf("failed reading sql file: %v", err)
}
mode := "dry-run"
if opts.Apply {
mode = "apply"
}
fmt.Printf("Mode: %s\n", mode)
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("SQL bytes: %d\n", len(sqlContent))
if !opts.Apply {
fmt.Println("Dry-run only. Add --apply to execute the SQL file.")
return
}
db := database.Connect(config.DBHost, config.DBName)
if err := executeSQL(db, sqlContent); err != nil {
log.Fatalf("failed executing sql file: %v", err)
}
fmt.Println("DONE: SQL executed successfully")
}
func readSQLFile(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", err
}
sql := strings.TrimSpace(strings.TrimPrefix(string(raw), "\ufeff"))
if sql == "" {
return "", fmt.Errorf("sql file is empty")
}
return sql, nil
}
func executeSQL(db *gorm.DB, sql string) error {
return db.Transaction(func(tx *gorm.DB) error {
return tx.Exec(sql).Error
})
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math"
"os"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type mismatchRow struct {
ChickinID uint `gorm:"column:chickin_id"`
ProjectFlockKandang uint `gorm:"column:project_flock_kandang_id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
UsageQty float64 `gorm:"column:usage_qty"`
TraceQty float64 `gorm:"column:trace_qty"`
}
func main() {
var projectFlockKandangID uint
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Optional project flock kandang scope")
flag.Parse()
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
rows, err := loadTraceMismatches(ctx, db, projectFlockKandangID)
if err != nil {
log.Fatalf("failed to load trace mismatches: %v", err)
}
activeConsumeRows, err := countActiveConsumeProjectChickin(ctx, db, projectFlockKandangID)
if err != nil {
log.Fatalf("failed to count active consume rows: %v", err)
}
fmt.Printf("Scope project_flock_kandang_id=%d\n", projectFlockKandangID)
fmt.Printf("Mismatched chickin trace rows: %d\n", len(rows))
fmt.Printf("Active PROJECT_CHICKIN consume rows: %d\n", activeConsumeRows)
if len(rows) > 0 {
for _, row := range rows {
fmt.Printf(
"MISMATCH chickin_id=%d pfk=%d pw=%d usage=%.3f trace=%.3f diff=%.3f\n",
row.ChickinID,
row.ProjectFlockKandang,
row.ProductWarehouseID,
row.UsageQty,
row.TraceQty,
row.TraceQty-row.UsageQty,
)
}
}
if len(rows) > 0 || activeConsumeRows > 0 {
os.Exit(1)
}
}
func loadTraceMismatches(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) ([]mismatchRow, error) {
query := db.WithContext(ctx).
Table("project_chickins pc").
Select(`
pc.id AS chickin_id,
pc.project_flock_kandang_id,
pc.product_warehouse_id,
COALESCE(pc.usage_qty, 0) AS usage_qty,
COALESCE(SUM(sa.qty), 0) AS trace_qty
`).
Joins(`
LEFT JOIN stock_allocations sa
ON sa.usable_type = ?
AND sa.usable_id = pc.id
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'TRACE_CHICKIN'
`, fifo.UsableKeyProjectChickin.String()).
Where("pc.deleted_at IS NULL").
Where("COALESCE(pc.usage_qty,0) > 0").
Group("pc.id, pc.project_flock_kandang_id, pc.product_warehouse_id, pc.usage_qty")
if projectFlockKandangID > 0 {
query = query.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID)
}
rows := make([]mismatchRow, 0)
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]mismatchRow, 0, len(rows))
for _, row := range rows {
if math.Abs(row.TraceQty-row.UsageQty) > 1e-3 {
out = append(out, row)
}
}
return out, nil
}
func countActiveConsumeProjectChickin(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, error) {
q := db.WithContext(ctx).
Table("stock_allocations sa").
Joins("JOIN project_chickins pc ON pc.id = sa.usable_id").
Where("sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Where("sa.status = 'ACTIVE'").
Where("sa.allocation_purpose = 'CONSUME'")
if projectFlockKandangID > 0 {
q = q.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID)
}
var count int64
if err := q.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
+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.
-77
View File
@@ -1,77 +0,0 @@
services:
postgresdb:
image: postgres:alpine
restart: always
ports:
- "${DB_PORT_HOST:-5542}:5432"
environment:
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_NAME:-db_lti_erp}
volumes:
- dbdata:/var/lib/postgresql/data
- ./internal/database/init:/docker-entrypoint-initdb.d
networks: [go-network]
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}",
]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "${REDIS_PORT_HOST:-6381}:6379"
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 5s
timeout: 3s
retries: 10
networks: [go-network]
app:
build:
context: .
dockerfile: Dockerfile.local
image: cosmtrek/air:v1.52.3
working_dir: /lti-api
volumes:
- .:/lti-api
- ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key
- ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub
command: air -c .air.toml
env_file:
- .env
environment:
DB_HOST: postgresdb
DB_PORT: 5432
DB_USER: ${DB_USER:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-postgres}
DB_NAME: ${DB_NAME:-db_lti_erp}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
ports:
- "${APP_PORT:-8081}:8081"
depends_on:
postgresdb:
condition: service_healthy
networks: [go-network]
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
volumes:
dbdata:
go-mod-cache:
go-build-cache:
networks:
go-network:
name: lti-api_go-network
driver: bridge
-98
View File
@@ -1,98 +0,0 @@
services:
dev-api-lti:
build:
context: .
dockerfile: Dockerfile
container_name: dev-api-lti
working_dir: /lti-api
command: ["/bin/sh", "scripts/entrypoint.sh"]
ports:
- "8081:8081"
env_file:
- .env
environment:
# override agar koneksi ke container internal
DB_HOST: dev-postgres-lti
DB_PORT: 5432
REDIS_URL: redis://dev-redis-lti:6379/0
volumes:
- .:/lti-api
- ./.air.toml:/lti-api/.air.toml:ro
- ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key
- ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub
depends_on:
- dev-postgres-lti
- dev-redis-lti
networks:
- lti-network
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
reservations:
cpus: "1.0"
memory: 512M
dev-postgres-lti:
image: postgres:15-alpine
container_name: dev-postgres-lti
restart: always
env_file:
- credential/.env.db
ports:
- "5433:5432"
volumes:
- dev-postgres-lti-data:/var/lib/postgresql/data
- ./credential:/docker-entrypoint-initdb.d:ro
networks:
- lti-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
deploy:
resources:
limits:
cpus: "1.0"
memory: 2G
reservations:
cpus: "0.5"
memory: 512M
dev-redis-lti:
image: redis:7-alpine
container_name: dev-redis-lti
restart: always
ports:
- "6380:6379"
networks:
- lti-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
reservations:
cpus: "0.2"
memory: 256M
networks:
lti-network:
driver: bridge
volumes:
dev-postgres-lti-data:
+460
View File
@@ -0,0 +1,460 @@
# Stock Consolidation Operations Guide
This guide explains how to use the warehouse consolidation commands to fix misplaced PAKAN/OVK stocks and migrate them to the correct farm-level warehouses.
## Overview
The stock consolidation system handles two main scenarios:
| Case | Scenario | Root Cause | Solution |
|------|----------|-----------|----------|
| **Case B** | Invalid kandang references | Purchases pointed to warehouses with location mismatch | Move unused stocks to correct farm-level warehouse |
| **Case A** | General kandang cleanup | Any kandang-level warehouse with unused PAKAN/OVK stocks | Consolidate to farm-level warehouse |
## Recommended Execution Order
For a complete stock consolidation operation, follow this sequence:
```
1. find-wrong-warehouse-records ← Diagnose issues
2. repoint-wrong-warehouse-relations ← Fix Case B (invalid references)
3. consolidate-kandang-to-farm-stocks ← Fix Case A (general cleanup)
4. verify-stock-consolidation ← Audit and verify results
```
---
## Command Reference
### 1. `find-wrong-warehouse-records` — Diagnostic Tool
**Purpose:** Identify problematic warehouses and their associated stocks before making any changes.
**Applies to:** Both Case A and Case B scenarios
**What it does:**
- Lists warehouses with location mismatches (Case B)
- Shows stock allocations that reference wrong warehouses
- Helps identify scope of work needed
#### Usage:
```bash
# Report 1: Find warehouses with location mismatches (Case B issues)
./find-wrong-warehouse-records --report=warehouses
# Report 2: Find stock allocations in wrong warehouses (Case B impact)
./find-wrong-warehouse-records --report=usage
# Filter by area
./find-wrong-warehouse-records --report=warehouses --area-name "East Region"
# Filter by kandang location
./find-wrong-warehouse-records --report=usage --kandang-location-name "Location 1"
# Filter by product type
./find-wrong-warehouse-records --report=usage --usable-type=RECORDING_STOCK
# JSON output for analysis
./find-wrong-warehouse-records --report=usage --output=json > analysis.json
```
#### Output Columns (Warehouses Report):
- **AREA**: Geographic area
- **KANDANG_LOCATION**: Kandang's intended location
- **KANDANG**: Kandang name
- **WRONG_LOCATION**: Where the warehouse actually is
- **WRONG_WAREHOUSE**: Problematic warehouse name
- **CORRECT_WAREHOUSE**: Where stocks should be
#### Output Columns (Usage Report):
- **USABLE_TYPE**: RECORDING_STOCK or MARKETING_DELIVERY
- **PRODUCTS**: Which products are affected
- **QTY_FROM_WRONG_STOCK**: How much stock is misplaced
- **SOURCE_PURCHASES**: Which purchase orders are affected
**When to use:**
- Before starting any consolidation
- To understand the scope of issues
- To get metrics on how much stock needs moving
- To identify which areas are most affected
---
### 2. `repoint-wrong-warehouse-relations` — Fix Case B (Invalid References)
**Purpose:** Fix purchases pointed to invalid kandang warehouses (location mismatch).
**Applies to:** Case B only
**Cases it handles:**
- ✅ Warehouses with `location_id ≠ kandang.location_id` (location mismatch)
- ✅ Only PAKAN/OVK products
- ✅ Only unused/leftover stocks (no active allocations)
- ✅ Moves to farm-level warehouse at correct location
**What it does:**
1. Finds product_warehouses in wrong locations
2. Consolidates duplicates into survivor warehouses
3. Updates all references across the system
4. Recalculates FIFO stocks if needed
5. Optionally soft-deletes the wrong warehouse
#### Usage:
```bash
# Dry-run: See what would be moved (always run first!)
./repoint-wrong-warehouse-relations
# Dry-run with specific filters
./repoint-wrong-warehouse-relations --area-name "East Region"
./repoint-wrong-warehouse-relations --kandang-location-name "Location 1"
# Actually apply the migration
./repoint-wrong-warehouse-relations --apply
# Apply but keep the wrong warehouses (for audit trail)
./repoint-wrong-warehouse-relations --apply --delete-wrong-warehouses=false
# JSON output for automation/logging
./repoint-wrong-warehouse-relations --apply --output=json > migration.json
```
#### Flags:
- `--apply`: Apply changes (omit for dry-run)
- `--output`: `table` (default) or `json`
- `--area-name`: Filter by exact area name
- `--kandang-location-name`: Filter by exact location name
- `--delete-wrong-warehouses`: Soft-delete wrong warehouses (default: true)
- `--db-sslmode`: PostgreSQL SSL mode override (e.g., `require`)
#### Output:
**Table mode shows:**
- AREA, LOCATION, KANDANG: Where the issue is
- WRONG_WAREHOUSE: Source (will be deleted)
- TARGET_WAREHOUSE: Destination (farm-level)
- PRODUCT: What's being moved
- SURVIVOR_PW / ABSORBED_PW: Consolidation details
- NEEDS_REFLOW: Whether FIFO recalculation is needed
**Summary shows:**
```
Summary: plan_rows=15 wrong_warehouses=3 survivor_pws=12 absorbed_pws=5
needs_reflow_pws=3 deleted_product_warehouses=5 soft_deleted_warehouses=3
Updated product_warehouse refs:
fifo_stock_v2_operation_log.product_warehouse_id=8
fifo_stock_v2_reflow_checkpoints.product_warehouse_id=3
purchase_items.warehouse_id=12
Updated warehouse refs:
purchase_items.warehouse_id=12
```
#### Safety Features:
- **Dry-run first**: Always preview before applying
- **Prechecks**: Verifies no blocked references or FIFO conflicts
- **Atomic transactions**: All-or-nothing database updates
- **Reference verification**: Confirms all references were updated
- **Stock log recalculation**: Ensures FIFO accuracy after moves
---
### 3. `consolidate-kandang-to-farm-stocks` — Fix Case A (General Cleanup)
**Purpose:** Consolidate ALL kandang-level PAKAN/OVK stocks to farm-level warehouse.
**Applies to:** Case A only
**Cases it handles:**
- ✅ ALL kandang-level warehouses (type ≠ 'LOKASI')
- ✅ Only PAKAN/OVK products
- ✅ Only unused/leftover stocks (no active allocations)
- ✅ Moves to farm-level warehouse regardless of warehouse validity
- ✅ No location validation (processes all kandang warehouses)
**What it does:**
1. Finds all kandang-level warehouses with unused stocks
2. Consolidates duplicates into survivor warehouses
3. Updates all references across the system
4. Recalculates FIFO stocks if needed
5. Optionally soft-deletes the kandang warehouse
#### Usage:
```bash
# Dry-run: See what would be consolidated
./consolidate-kandang-to-farm-stocks
# Dry-run with filters
./consolidate-kandang-to-farm-stocks --area-name "East Region"
./consolidate-kandang-to-farm-stocks --kandang-location-name "Location 1"
# Actually apply the consolidation
./consolidate-kandang-to-farm-stocks --apply
# Apply but keep kandang warehouses
./consolidate-kandang-to-farm-stocks --apply --delete-kandang-warehouses=false
# JSON output for logging
./consolidate-kandang-to-farm-stocks --apply --output=json > consolidation.json
```
#### Flags:
- `--apply`: Apply changes (omit for dry-run)
- `--output`: `table` (default) or `json`
- `--area-name`: Filter by exact area name
- `--kandang-location-name`: Filter by exact location name
- `--delete-kandang-warehouses`: Soft-delete kandang warehouses (default: true)
- `--db-sslmode`: PostgreSQL SSL mode override
#### Output Format:
Similar to Case B, shows:
- Source kandang warehouse → Destination farm warehouse
- Product and quantity details
- Consolidation and FIFO reflow information
#### Key Differences from Case B:
| Aspect | Case B | Case A |
|--------|--------|--------|
| Scope | Wrong-location warehouses only | ALL kandang-level warehouses |
| Validation | Checks location mismatch | No validation checks |
| When to use | After finding mismatches | General cleanup/consolidation |
| Risk level | Lower (targeted fix) | Higher (broader scope) |
---
### 4. `verify-stock-consolidation` — Audit and Verify
**Purpose:** Verify that stock consolidations were successful and no stocks were lost.
**Applies to:** Both Case A and Case B (post-migration verification)
**What it checks:**
#### ✅ Source Warehouse Verification
Ensures deleted warehouses are clean:
- **CLEAN**: No remaining stock or purchase references
- **DIRTY**: Still has orphaned data (migration incomplete)
#### ✅ Destination Warehouse Verification
Ensures farm-level warehouses received stocks correctly:
- **MATCHED**: Quantity in product_warehouse matches stock_logs
- **DISCREPANCY**: Quantity mismatch (data integrity issue!)
- **EMPTY**: No stocks (correct if nothing was supposed to move)
#### ✅ Orphaned Reference Detection
Finds any remaining references to deleted warehouses in:
- `purchase_items.warehouse_id`
- `stock_transfers.from/to_warehouse_id`
- `fifo_stock_v2_operation_log.warehouse_id`
#### Usage:
```bash
# Verify all consolidations (Case A + B together)
./verify-stock-consolidation
# Verify only Case B results
./verify-stock-consolidation --verify-case=B
# Verify only Case A results
./verify-stock-consolidation --verify-case=A
# Filter by area
./verify-stock-consolidation --area-name "East Region"
# Filter by location
./verify-stock-consolidation --kandang-location-name "Location 1"
# JSON output for reporting
./verify-stock-consolidation --output=json > verification_report.json
```
#### Flags:
- `--verify-case`: `A`, `B`, or `all` (default)
- `--output`: `table` (default) or `json`
- `--area-name`: Filter by exact area name
- `--kandang-location-name`: Filter by exact location name
- `--db-sslmode`: PostgreSQL SSL mode override
#### Output Sections:
**1. Source Warehouses**
```
AREA LOKASI KANDANG WAREHOUSE CASE DELETED_AT STOCK PURCHASES STATUS
Area A Location 1 Kandang A KWH-A-01 A 2026-04-23 0.000 0 CLEAN
Area A Location 1 Kandang B WH-WRONG-001 B 2026-04-23 2.500 1 DIRTY ❌
```
**2. Destination Warehouses**
```
AREA LOKASI FARM_WAREHOUSE PRODUCT QTY LOGS_TOTAL LOGS STATUS
Area A Location 1 FWH-LOC-001 PAKAN A 2.500 2.500 3 MATCHED ✅
Area A Location 1 FWH-LOC-001 OVK B 5.000 4.999 5 DISCREPANCY ❌
```
**3. Orphaned References** (if any)
```
TABLE COLUMN COUNT WAREHOUSE_IDS
purchase_items warehouse_id 3 1001, 1002, 1003
stock_transfers from_warehouse_id 1 1001
```
**4. Summary**
```
Source Warehouses: 10 total, 8 clean, 2 dirty
Destination Warehouses: 15 total, 14 matching, 1 discrepancy
Orphaned References: 4
Overall Status: FAIL ❌
```
#### Interpreting Results:
| Scenario | Meaning | Action |
|----------|---------|--------|
| ✅ Overall Status: PASS | All migrations successful | No action needed |
| ❌ Dirty Source Warehouses | Stocks not fully moved | Re-run repoint/consolidate |
| ❌ Discrepancy Destinations | Quantity mismatch | Investigate data integrity |
| ❌ Orphaned References | Broken references remain | Manual cleanup needed |
---
## Complete Workflow Example
### Scenario: Consolidate East Region stocks
```bash
# Step 1: Understand the scope (Case B issues)
./find-wrong-warehouse-records --report=warehouses --area-name "East Region"
./find-wrong-warehouse-records --report=usage --area-name "East Region"
# Review the output to understand:
# - How many wrong warehouses
# - How much stock needs moving
# - Which products are affected
# Step 2: Fix Case B (invalid kandang references)
./repoint-wrong-warehouse-relations --area-name "East Region"
# Review dry-run output
./repoint-wrong-warehouse-relations --apply --area-name "East Region"
# Watch for summary - should show successful updates
# Step 3: Fix Case A (general kandang cleanup)
./consolidate-kandang-to-farm-stocks --area-name "East Region"
# Review dry-run output
./consolidate-kandang-to-farm-stocks --apply --area-name "East Region"
# Watch for summary - should show consolidation complete
# Step 4: Verify everything worked
./verify-stock-consolidation --area-name "East Region"
# Should show:
# - All source warehouses: CLEAN
# - All destination warehouses: MATCHED
# - Orphaned references: 0
# - Overall Status: PASS ✅
```
---
## Flags Reference
### Common Flags (All Commands)
| Flag | Description | Example |
|------|-------------|---------|
| `--output` | Output format | `--output=json` |
| `--area-name` | Filter by area | `--area-name "East Region"` |
| `--kandang-location-name` | Filter by location | `--kandang-location-name "Location 1"` |
| `--db-sslmode` | PostgreSQL SSL mode | `--db-sslmode=require` |
### Migration-Specific Flags
| Command | Flag | Description |
|---------|------|-------------|
| `repoint-wrong-warehouse-relations` | `--apply` | Apply changes |
| `repoint-wrong-warehouse-relations` | `--delete-wrong-warehouses` | Delete wrong warehouses (default: true) |
| `consolidate-kandang-to-farm-stocks` | `--apply` | Apply changes |
| `consolidate-kandang-to-farm-stocks` | `--delete-kandang-warehouses` | Delete kandang warehouses (default: true) |
| `verify-stock-consolidation` | `--verify-case` | Verify specific case (A, B, or all) |
---
## Best Practices
### Before Running Any Command
1. **Back up the database** — These operations modify stock data
2. **Run in dry-run mode first** — Always preview changes before applying
3. **Check during low-traffic periods** — Avoid peak hours
4. **Have a rollback plan** — Know how to restore from backup if needed
### When Running Migrations
1. **Start small** — Use `--area-name` to test on one area first
2. **Check the summary** — Verify numbers make sense
3. **Watch for errors** — Stop if you see unexpected error messages
4. **Run verification immediately after** — Don't wait to verify
### Red Flags (Stop and Investigate)
- ❌ More rows affected than expected
- ❌ Negative quantities or zero counts where expecting data
- ❌ Errors about blocked references
- ❌ FIFO conflicts or in-flight artifacts
- ❌ Very large numbers in NEEDS_REFLOW
### JSON Output for Automation
All commands support `--output=json` for:
- Piping to other tools
- Parsing in scripts
- Generating reports
- Integration with monitoring systems
```bash
# Example: Extract all affected warehouses to CSV
./find-wrong-warehouse-records --report=warehouses --output=json \
| jq -r '.rows[] | [.area_name, .kandang_name, .wrong_warehouse_name] | @csv' \
> affected_warehouses.csv
```
---
## Troubleshooting
### Issue: "No wrong warehouse relations found"
- **Cause**: No matching Case B issues in the filter scope
- **Solution**: Remove filters or use different criteria
### Issue: "found X rows still point to wrong warehouses"
- **Cause**: References not fully migrated
- **Solution**: Check for blocked references, re-run command
### Issue: "discrepancy_destinations > 0" in verification
- **Cause**: Quantity mismatch in farm warehouse
- **Solution**: Investigate manually or rollback and retry
### Issue: "DIRTY source warehouses" in verification
- **Cause**: Deleted warehouses still have stock/references
- **Solution**: May need manual cleanup or re-run migrations
---
## Performance Notes
- Commands use efficient SQL queries with proper filtering
- Large operations (100K+ rows) may take a few minutes
- Use area/location filters to reduce scope for testing
- Dry-runs don't modify database and complete quickly
## Support
For issues or questions:
1. Review the relevant section of this guide
2. Check the command output for specific error messages
3. Run verification to diagnose state issues
4. Contact the development team with JSON outputs from failed operations
@@ -0,0 +1,76 @@
# Farm Depreciation Manual Inputs Import
Command ini dipakai untuk bulk import data ke tabel `farm_depreciation_manual_inputs` dari file Excel (`.xlsx`).
## Command
```bash
go run ./cmd/import-farm-depreciation-manual-inputs --file <path.xlsx> [--sheet <name>] [--apply]
```
## Flags
- `--file` (required): path file `.xlsx`.
- `--sheet` (optional): nama sheet. Jika tidak diisi, command pakai sheet pertama.
- `--apply` (optional): default `false` (dry-run). Jika `true`, command menulis ke database.
## Mode
- Dry-run (default):
- parsing dan validasi semua baris.
- validasi `project_flock_id` terhadap farm aktif kategori `LAYING`.
- menampilkan `PLAN` + daftar error.
- tidak menulis data.
- Apply (`--apply`):
- semua validasi tetap dijalankan dulu.
- jika ada 1 error, proses dihentikan.
- jika valid, upsert dijalankan dalam 1 transaksi (fail-fast).
- setelah upsert, snapshot di `farm_depreciation_snapshots` dihapus mulai `cutover_date` untuk `project_flock_id` terkait.
## Format Excel
Template tersedia di:
- `docs/templates/farm_depreciation_manual_inputs.xlsx`
Header wajib ada di baris 1 (case-insensitive, trim-spaces):
- `project_flock_id` (required, integer > 0)
- `total_cost` (required, numeric >= 0)
- `cutover_date` (required, format `YYYY-MM-DD`)
- `note` (optional, max 1000 karakter)
Catatan:
- Dalam 1 file tidak boleh ada duplikat `project_flock_id`.
- `project_flock_id` harus mengarah ke `project_flocks` yang `deleted_at IS NULL` dan `category = LAYING`.
## Contoh
Dry-run:
```bash
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
go run ./cmd/import-farm-depreciation-manual-inputs \
--file docs/templates/farm_depreciation_manual_inputs.xlsx
```
Apply:
```bash
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
go run ./cmd/import-farm-depreciation-manual-inputs \
--file /path/to/farm_depreciation_manual_inputs.xlsx \
--sheet manual_inputs \
--apply
```
## Error Umum
- `required header is missing`: header wajib tidak ditemukan.
- `must be a positive integer`: `project_flock_id` bukan integer valid.
- `must be greater than or equal to 0`: `total_cost` negatif.
- `must follow format YYYY-MM-DD`: `cutover_date` tidak sesuai format.
- `duplicate value ...`: `project_flock_id` duplikat dalam file yang sama.
- `must reference an active LAYING project_flock`: farm tidak valid untuk import ini.
+31
View File
@@ -0,0 +1,31 @@
# Farm Stock Attribution Design Note
## Goal
Allow farm-level physical stock to be used directly by kandang-level operations without forcing transfers, while keeping kandang attribution, FIFO-v2 compatibility, traceability, and HPP/COGS intact.
## Core Model
- Physical stock stays on the real `product_warehouse_id` that was consumed or received.
- Kandang attribution comes from the transaction or allocation path, not from `product_warehouses.project_flock_kandang_id`.
- Existing kandang-bound warehouses remain valid for historical and current kandang-only flows.
- Shared farm warehouses must stay shareable; application code must stop silently converting them into kandang-owned warehouses.
## Attribution Rules
- `recording_stocks`: consumer kandang is the parent `recordings.project_flock_kandangs_id`; physical stock source remains `recording_stocks.product_warehouse_id`.
- `recording_depletions`: source kandang is the recording kandang and is stored explicitly for compatibility; physical source remains `source_product_warehouse_id`, destination stock remains `product_warehouse_id`.
- `recording_eggs`: producer kandang is the recording kandang and is stored explicitly for compatibility; physical stock remains `product_warehouse_id`, which may be a farm warehouse.
- `marketing_delivery_products`: outbound kandang attribution comes from active `stock_allocations` to `PROJECT_FLOCK_POPULATION`, `RECORDING_DEPLETION`, or `RECORDING_EGG`, with product-warehouse kandang ownership only as a fallback for historical/non-FIFO rows.
## Reporting and HPP
- Feed and OVK cost attribution should continue to follow recording-level consumption plus FIFO allocations to incoming stock.
- Egg and live-bird sales attribution should be derived from `stock_allocations` back to the originating kandang transactions or populations.
- Queries that filter or group by kandang must use explicit transaction attribution or FIFO allocation provenance, not warehouse ownership, when pooled farm stock is involved.
## Live-Data Safety
- Schema changes are additive and nullable.
- Historical rows are backfilled only when attribution is deterministic from existing rows.
- No FIFO-v2 route-rule behavior is changed unless the current code is only resyncing or constraining allocation metadata around already-created FIFO allocations.
+286
View File
@@ -0,0 +1,286 @@
# Runbook Cutover Stok Telur Historis Kandang ke Gudang Farm
## Tujuan
Runbook ini dipakai untuk memindahkan **stok telur historis yang masih on-hand di gudang kandang** ke **gudang farm** secara aman, audit-able, dan reversible.
Cutover dilakukan dengan **transfer stok eksplisit**, bukan dengan mengubah `recording_eggs.product_warehouse_id` historis.
## Scope
Runbook ini hanya untuk:
- stok telur historis kandang-level yang masih punya saldo on-hand
- lokasi yang masuk kategori **clean cutover**
- lokasi yang sudah punya gudang farm
Runbook ini **tidak** dipakai untuk:
- lokasi overlap seperti `Cijangkar`
- koreksi histori `recording_eggs`
- migrasi stok non-telur
## Kebijakan yang Dikunci
- Sumber qty yang dipindah adalah **`product_warehouses.qty` saat cutover**
- Perintah dijalankan **per lokasi**
- Wajib mulai dari `dry-run`
- `--apply` hanya boleh dijalankan setelah review dry-run dan SQL checklist
- Lokasi overlap tidak ikut otomatis kecuali ada approval khusus dan `--include-overlap`
- Rollback hanya boleh dilakukan jika transfer hasil cutover belum dipakai transaksi turunan
## Lokasi Fase 1
Lokasi yang boleh dieksekusi pada fase pertama:
- `Jamali`
- `Cantilan`
- `Darawati`
- `Tamansari`
Lokasi yang harus ditahan:
- `Cijangkar`
## Prasyarat
Sebelum eksekusi, pastikan:
- backend sudah ter-deploy dengan command [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go)
- reusable transfer core sudah ikut ter-deploy:
- [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go)
- [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go)
- migrasi farm stock attribution sebelumnya sudah terpasang
- akses database target sudah tersedia
- environment target memakai SSL bila RDS mewajibkan, contoh:
- `DB_SSLMODE=require`
## Catatan Output Command
Mode `--output table` adalah mode operasional yang direkomendasikan.
Mode `--output json` bisa dipakai, tetapi pada environment saat ini output JSON masih dapat didahului log bootstrap aplikasi atau SQL logger. Untuk review manual gunakan `table`. Untuk parsing otomatis, filter payload mulai dari `{`.
## Format Command
### Dry-run
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--output table
```
### Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--cutover-date 2026-04-07 \
--apply \
--output table
```
### Rollback Preview
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--output table
```
### Rollback Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--apply \
--output table
```
## Arti `run_id`
Setiap dry-run/apply menghasilkan `run_id`, misalnya:
```text
egg-cutover-20260407T130344.220407000Z
```
`run_id` ini wajib disimpan karena dipakai untuk:
- audit hasil cutover
- query verifikasi
- rollback
## Prosedur Eksekusi Per Lokasi
### 1. Persiapan
Tentukan:
- `location_name`
- `cutover_date`
- operator yang bertanggung jawab
Contoh:
- lokasi: `Jamali`
- cutover date: `2026-04-07`
### 2. Jalankan Dry-run
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--output table
```
Yang harus dicek pada hasil dry-run:
- status lokasi `CLEAN_CUTOVER`
- semua baris yang akan dipindah punya `status=eligible`
- gudang tujuan adalah gudang farm lokasi tersebut
- qty yang dipindah masuk akal dan sesuai saldo on-hand aktual
- tidak ada `missing_farm_warehouse`
- tidak ada `overlap_location`
### 3. Jalankan Checklist SQL Before
Gunakan file:
- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
Minimal pastikan:
- lokasi memang clean cutover
- stok telur kandang positif masih ada
- gudang farm ada
- belum ada transfer `EGG_FARM_CUTOVER` aktif untuk lokasi yang sama pada run yang akan dipakai
### 4. Simpan Evidence Sebelum Apply
Simpan:
- output dry-run
- hasil query before
- nama operator
- waktu eksekusi
Disarankan simpan dalam ticket / change record.
### 5. Jalankan Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--cutover-date 2026-04-07 \
--apply \
--output table
```
Setelah apply, simpan:
- `run_id`
- seluruh row dengan `transfer_id`
- movement number yang terbentuk
### 6. Jalankan Checklist SQL After
Masih menggunakan file:
- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
Minimal pastikan:
- transfer header/detail tercatat untuk `run_id`
- qty source berkurang sesuai transfer
- qty farm bertambah sesuai transfer
- total gabungan source+dest per produk per lokasi tetap sama
- stok eligible tidak lagi tersedia di gudang kandang
- stok telur sekarang tersedia di gudang farm
### 7. Smoke Test UI
Lakukan minimal:
- buka product stock farm untuk lokasi tersebut
- pastikan produk telur hasil migrasi muncul
- buat SO farm-level dan pastikan opsi produk telur tersedia
- pastikan recording telur baru setelah cutover tetap langsung masuk ke gudang farm
### 8. Tutup Eksekusi
Catat hasil akhir:
- sukses/gagal
- `run_id`
- lokasi
- tanggal cutover
- operator
- link ke evidence SQL/UI
## Kriteria Go / No-Go
### Boleh lanjut apply bila:
- dry-run menunjukkan hanya row yang memang expected
- lokasi `CLEAN_CUTOVER`
- gudang farm valid
- query before menunjukkan tidak ada anomaly blocking
### Wajib stop bila:
- lokasi terdeteksi `OVERLAP`
- ada qty aneh atau tidak sesuai data lapangan
- gudang farm tidak ada
- ada transfer lama serupa yang belum direkonsiliasi
- setelah apply terjadi selisih total source+dest
## Rollback Runbook
### Kapan rollback boleh dilakukan
Rollback boleh jika:
- transfer hasil cutover belum dipakai transaksi turunan
- verifikasi after menunjukkan issue yang membuat hasil cutover tidak dapat diterima
### Langkah rollback
1. Preview rollback:
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--output table
```
2. Jalankan query rollback readiness pada file audit/helper SQL.
3. Jika aman, apply rollback:
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--apply \
--output table
```
4. Jalankan ulang query verifikasi after rollback.
### Kapan rollback akan gagal by design
Rollback memang harus gagal jika:
- transfer hasil cutover sudah dipakai sales/recording/transaksi turunan
- sudah ada `stock_allocations` consume aktif terhadap `STOCK_TRANSFER_IN`
## Urutan Rollout yang Direkomendasikan
### Dev
1. Dry-run per lokasi
2. Review SQL before
3. Apply per lokasi
4. SQL after
5. Smoke UI
6. Simpan `run_id`
### Production
1. Freeze operasional lokasi target bila perlu
2. Dry-run
3. Review by dev + ops + finance/stock owner
4. Apply
5. SQL after
6. Smoke UI
7. Release lokasi berikutnya
## Referensi
- Command cutover: [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go)
- Test command: [main_test.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main_test.go)
- Core reusable transfer: [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go)
- Transfer service refactor: [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go)
- Checklist SQL: [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
- Helper query audit: [legacy_egg_cutover_audit_queries.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_audit_queries.sql)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
{
"_postman_exported_at": "2026-04-14T00:00:00Z",
"_postman_exported_using": "Codex",
"_postman_variable_scope": "environment",
"id": "lti-read-api-local",
"name": "LTI ERP Read API.local",
"values": [
{
"enabled": true,
"key": "adjustment_id",
"value": "1"
},
{
"enabled": true,
"key": "api_key",
"value": ""
},
{
"enabled": true,
"key": "area_id",
"value": "1"
},
{
"enabled": true,
"key": "bank_id",
"value": "1"
},
{
"enabled": true,
"key": "base_url",
"value": "http://localhost:8081"
},
{
"enabled": true,
"key": "bearer_token",
"value": ""
},
{
"enabled": true,
"key": "chickin_id",
"value": "1"
},
{
"enabled": true,
"key": "customer_id",
"value": "1"
},
{
"enabled": true,
"key": "employee_id",
"value": "1"
},
{
"enabled": true,
"key": "expense_id",
"value": "1"
},
{
"enabled": true,
"key": "flock_id",
"value": "1"
},
{
"enabled": true,
"key": "id",
"value": "1"
},
{
"enabled": true,
"key": "idDailyChecklist",
"value": "1"
},
{
"enabled": true,
"key": "idProjectFlockKandang",
"value": "1"
},
{
"enabled": true,
"key": "initial_balance_id",
"value": "1"
},
{
"enabled": true,
"key": "injection_id",
"value": "1"
},
{
"enabled": true,
"key": "location_id",
"value": "1"
},
{
"enabled": true,
"key": "nonstock_id",
"value": "1"
},
{
"enabled": true,
"key": "payment_id",
"value": "1"
},
{
"enabled": true,
"key": "product_category_id",
"value": "1"
},
{
"enabled": true,
"key": "product_id",
"value": "1"
},
{
"enabled": true,
"key": "projectFlockId",
"value": "1"
},
{
"enabled": true,
"key": "project_flock_id",
"value": "1"
},
{
"enabled": true,
"key": "project_flock_kandang_id",
"value": "1"
},
{
"enabled": true,
"key": "purchase_id",
"value": "1"
},
{
"enabled": true,
"key": "recording_id",
"value": "1"
},
{
"enabled": true,
"key": "supplier_id",
"value": "1"
},
{
"enabled": true,
"key": "transaction_id",
"value": "1"
},
{
"enabled": true,
"key": "transfer_id",
"value": "1"
},
{
"enabled": true,
"key": "uniformity_id",
"value": "1"
},
{
"enabled": true,
"key": "uom_id",
"value": "1"
},
{
"enabled": true,
"key": "user_id",
"value": "1"
},
{
"enabled": true,
"key": "warehouse_id",
"value": "1"
}
]
}
+45
View File
@@ -0,0 +1,45 @@
ID;Kategori;Area;Judul;Tipe;Prioritas;Setup/Precondition;Langkah Uji;Hasil yang Diharapkan
TC-A01;Migrasi dan Keamanan Data;Database;Migrasi aman pada DB tidak kosong;Integration;High;Gunakan snapshot DB staging yang sudah berisi recording, depletion, telur, penjualan, dan closing.;1. Jalankan migrasi 20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql. 2. Inspect schema hasil migrasi.;Kolom recording_depletions.source_project_flock_kandang_id dan recording_eggs.project_flock_kandang_id tersedia dan nullable, index dan FK tersedia, tidak ada data historis yang terhapus atau berubah destruktif.
TC-A02;Migrasi dan Keamanan Data;Database;Backfill deterministik berjalan;Integration;High;Ada data historis recording dengan recordings.project_flock_kandangs_id yang valid.;1. Query recording_depletions dan recording_eggs yang lama. 2. Bandingkan dengan kandang pada parent recording.;source_project_flock_kandang_id dan project_flock_kandang_id terisi sama dengan kandang parent recording untuk row yang sebelumnya null.
TC-A03;Migrasi dan Keamanan Data;Reporting;Report historis kandang-only tidak berubah;Regression;High;Gunakan snapshot yang hanya memiliki data stok historis milik kandang, tanpa pooled stock farm-level.;1. Jalankan closing/report/HPP sebelum deploy. 2. Jalankan lagi sesudah deploy pada snapshot yang sama. 3. Bandingkan hasil.;Total dan hasil report tetap sama untuk skenario historis kandang-only.
TC-B01;Purchase dan Warehouse;Purchase;Purchase pakan langsung ke gudang farm;UAT;High;Tersedia PO atau purchase request untuk produk Pakan Starter.;1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase.;Stok masuk ke product_warehouse level farm, tidak perlu transfer paksa ke kandang, FIFO/HPP purchase tetap benar.
TC-B02;Purchase dan Warehouse;Purchase;Purchase pakan langsung ke gudang kandang;Regression;High;Tersedia PO atau purchase request untuk produk Pakan Starter.;1. Buat purchase ke Gudang Kandang A1. 2. Approve dan receive purchase.;Stok masuk ke gudang kandang dan perilaku tetap sama seperti flow lama.
TC-B03;Purchase dan Warehouse;Purchase;Purchase OVK langsung ke gudang farm;UAT;High;Tersedia PO atau purchase request untuk produk OVK A.;1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase.;Stok OVK masuk ke gudang farm dan bisa dipakai kemudian pada recording.
TC-B04;Purchase dan Warehouse;Product Warehouse;Gudang farm shared tidak diubah diam-diam menjadi milik kandang;Regression;High;Sudah ada row product_warehouse level farm untuk Pakan Starter di Gudang Farm A.;1. Trigger flow yang memanggil ensure/find product warehouse untuk produk yang sama. 2. Inspect row existing.;Row farm-level tetap farm-level, project_flock_kandang_id tidak dibackfill diam-diam, row khusus kandang dibuat terpisah bila memang diperlukan.
TC-C01;Recording Stock Consumption;Recording;Recording kandang memakai pakan dari gudang kandang;Regression;High;Stok pakan tersedia di Gudang Kandang A1.;1. Buka recording untuk Kandang A1. 2. Pilih pakan dari gudang kandang. 3. Submit dan approve.;Recording berhasil, stok keluar dari product_warehouse kandang, atribusi kandang tetap A1, HPP pemakaian muncul di closing/HPP A1.
TC-C02;Recording Stock Consumption;Recording;Recording kandang memakai pakan dari gudang farm;UAT;High;Stok pakan hanya tersedia di Gudang Farm A.;1. Buka recording untuk Kandang A1. 2. Pilih stok pakan farm-level. 3. Submit dan approve.;Recording berhasil tanpa transfer ke kandang, stok fisik berkurang dari gudang farm, usage/HPP tetap teratribusi ke Kandang A1, closing farm dan kandang tetap bisa dihitung.
TC-C03;Recording Stock Consumption;Recording;Recording kandang memakai OVK dari gudang farm;UAT;High;Stok OVK hanya tersedia di Gudang Farm A.;1. Buka recording untuk Kandang A1. 2. Pilih stok OVK farm-level. 3. Submit dan approve.;Stok OVK keluar dari gudang farm dan biaya pemakaian teratribusi ke kandang yang dipilih.
TC-C04;Recording Stock Consumption;Frontend Recording;Selector recording menampilkan opsi stok farm dan kandang dengan jelas;UI Regression;Medium;Produk yang sama tersedia di Gudang Farm A dan Gudang Kandang A1.;1. Buka form recording untuk A1. 2. Buka selector pakan.;Kedua opsi terlihat, label membedakan gudang atau scope dengan jelas, farm stock tidak tersembunyi secara salah.
TC-C05;Recording Stock Consumption;Recording;Recording A1 tidak boleh memakai stok kandang A2;Negative;High;Pakan Starter tersedia di Gudang Kandang A2.;1. Buka recording untuk A1. 2. Periksa opsi stok yang bisa dipilih.;Opsi Gudang Kandang A2 tidak bisa dipilih, stok farm tetap bisa dipilih.
TC-C06;Recording Stock Consumption;Recording;Perilaku pending stock dan usage lama tetap berjalan;Regression;Medium;Tidak ada setup khusus selain data recording yang valid.;1. Buat usage stock. 2. Buka kembali halaman edit dan detail.;Tampilan dan perhitungan pending atau usage tetap benar, tidak ada regresi pada route FIFO-v2.
TC-D01;Recording Telur dan Atribusi;Recording;Recording telur ke gudang kandang tetap berjalan;Regression;High;Kandang A1 aktif dan gudang telur kandang tersedia.;1. Record telur untuk A1 ke Gudang Kandang A1. 2. Approve.;Stok telur di gudang kandang bertambah dan asal kandang tetap A1.
TC-D02;Recording Telur dan Atribusi;Recording;Recording telur di kandang menyimpan stok ke gudang farm;UAT;High;Egg product warehouse tersedia di Gudang Farm A.;1. Record telur untuk A1. 2. Pilih Gudang Farm A sebagai gudang telur. 3. Submit dan approve.;Stok telur fisik masuk ke gudang farm, recording_eggs.project_flock_kandang_id bernilai A1, tidak ada transfer paksa ke kandang.
TC-D03;Recording Telur dan Atribusi;Reporting;Stok telur pooled di farm tetap punya jejak asal kandang;Integration;High;A1 record 100 telur ke gudang farm dan A2 record 150 telur ke gudang farm yang sama.;1. Inspect row telur yang tersimpan. 2. Inspect hasil costing atau report setelahnya.;Stok fisik pooled di gudang farm, tetapi asal kandang tetap bisa dibedakan per row atau allocation, HPP per kandang tetap dapat dihitung.
TC-D04;Recording Telur dan Atribusi;Recording Detail;Known gap pada detail recording dipahami;Known Limitation;Low;Sudah menjalankan TC-D02.;1. Buka detail recording setelah transaksi telur ke gudang farm.;Logika bisnis tetap berjalan, tetapi detail API atau UI mungkin belum menampilkan egg-origin secara eksplisit karena detail DTO belum diperluas.
TC-E01;Depletion dan Atribusi Populasi;Recording;Depletion dari gudang ayam milik kandang normal;Regression;High;A1 memiliki populasi ayam di gudang kandang.;1. Buat depletion. 2. Approve.;Depletion berhasil, alokasi populasi ter-resolve ke A1, HPP atau usage tetap benar.
TC-E02;Depletion dan Atribusi Populasi;Recording;Depletion dari sumber ayam fisik farm-level dengan source kandang A1;UAT;High;Stok ayam secara fisik ada di gudang farm dan punya jejak sumber ke A1.;1. Buat depletion untuk A1. 2. Gunakan path source atau farm-level yang didukung backend. 3. Approve.;source_product_warehouse_id menunjuk ke sumber fisik yang benar, source_project_flock_kandang_id bernilai A1, alokasi populasi berhasil tanpa mengasumsikan gudang fisik milik A1.
TC-E03;Depletion dan Atribusi Populasi;Recording;Depletion gagal bila sumber populasi tidak dapat diatribusikan;Negative;High;Buat kasus stok ayam farm-level tanpa source kandang yang valid.;1. Coba approve depletion.;Backend menolak dengan error yang jelas dan tidak ada silent misattribution.
TC-F01;Marketing dan Penjualan;Sales Order;Sales order dari gudang kandang tetap berjalan;Regression;High;Stok produk tersedia di Gudang Kandang A1.;1. Buat SO dari Gudang Kandang A1. 2. Lakukan delivery.;Perilaku lama tetap berjalan normal.
TC-F02;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk telur;UAT;High;Stok telur farm-level tersedia dan berasal dari A1.;1. Buat SO menggunakan Gudang Farm A. 2. Lakukan delivery.;SO dan DO berhasil, stok fisik berkurang dari gudang farm, HPP dan COGS telur tetap teratribusi ke kandang penghasil melalui allocation.
TC-F03;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk telur pooled A1 dan A2;Integration;High;Stok telur pooled tersedia di gudang farm dari A1 dan A2.;1. Buat penjualan. 2. Lakukan delivery. 3. Inspect closing atau report.;Stok fisik berkurang sekali dari gudang farm, revenue dan HPP terbagi benar ke A1 dan A2, tidak bergantung pada pw.project_flock_kandang_id.
TC-F04;Marketing dan Penjualan;Sales Order;Sales order dari gudang farm untuk ayam atau culling;UAT;High;Stok ayam atau culling farm-level tersedia dengan jejak sumber dari A1 dan A2.;1. Buat SO dari gudang farm. 2. Buat DO dan approve.;allocatePopulationForMarketingDelivery menurunkan atribusi kandang dari source groups atau allocation, tidak gagal karena gudang jual tidak punya project_flock_kandang_id, HPP dan COGS teratribusi ke kandang sumber.
TC-F05;Marketing dan Penjualan;Frontend Marketing;UI sales menampilkan semantik Gudang Fisik;UI Regression;Medium;Tidak ada setup khusus selain akses ke form SO.;1. Buka form SO. 2. Periksa label selector gudang dan label tabel produk.;UI menggunakan label Gudang Fisik, bukan Kandang yang menyesatkan, dan label produk memuat detail produk serta gudang atau scope.
TC-F06;Marketing dan Penjualan;Delivery Order;Layar delivery order tetap kompatibel;Regression;Medium;Sudah ada SO dari gudang farm.;1. Lakukan delivery untuk SO farm-level. 2. Periksa tabel dan detail DO.;Tidak ada masalah payload, gudang fisik tampil dengan benar, dan tidak ada kebingungan akibat wording lama berbasis kandang.
TC-G01;Report, Closing, dan HPP;Daily Marketing Report;Daily marketing report untuk penjualan telur farm-level;UAT;Medium;Sudah menjalankan TC-F02.;1. Jalankan daily marketing report. 2. Uji export.;Row muncul pada gudang fisik yang benar, report tidak menyiratkan gudang sama dengan kandang, export berjalan.
TC-G02;Report, Closing, dan HPP;Closing Sales;Closing sales untuk penjualan pooled farm-level;UAT;High;Ada penjualan pooled telur atau ayam dari gudang farm.;1. Buka closing sales.;Penjualan bisa tampil teratribusi per kandang, label menunjukkan Kandang Atribusi, HPP dan revenue tetap benar secara matematis.
TC-G03;Report, Closing, dan HPP;HPP per Kandang;HPP per kandang mencakup konsumsi pakan atau OVK dari gudang farm;UAT;High;A1 sudah memakai pakan atau OVK dari gudang farm.;1. Jalankan report HPP per kandang.;Biaya usage muncul di A1 dan tidak hilang walaupun gudang fisiknya level farm.
TC-G04;Report, Closing, dan HPP;Closing Sapronak;Outgoing sapronak menampilkan gudang fisik dengan benar;UI Regression;Medium;Ada data outgoing sapronak yang valid.;1. Buka tabel closing outgoing sapronak.;Header jelas menunjukkan Gudang Asal (Fisik) dan Gudang Tujuan (Fisik).
TC-G05;Report, Closing, dan HPP;Compatibility;Data historis kandang-owned dan pooled data baru dapat coexist;Regression;High;Dalam satu date range ada transaksi lama kandang-owned dan transaksi baru pooled farm-level.;1. Jalankan closing. 2. Jalankan report. 3. Jalankan HPP.;Kedua jenis data diproses dengan benar, tidak ada double count dan tidak ada atribusi yang hilang.
TC-H01;FIFO-v2 dan Integritas Allocation;FIFO-v2;Kontrak FIFO-v2 tidak berubah;Integration;High;Gunakan data uji yang mencakup recording stock, depletion, egg, dan marketing.;1. Verifikasi route FIFO untuk RECORDING_STOCK_OUT, RECORDING_DEPLETION_OUT, RECORDING_DEPLETION_IN, RECORDING_EGG_IN, dan MARKETING_OUT. 2. Bandingkan dengan RFC.md dan seed config FIFO-v2.;Tidak ada perubahan semantik route yang tidak disengaja.
TC-H02;FIFO-v2 dan Integritas Allocation;Stock Allocation;Stock allocation tetap konsisten untuk pakan dari gudang farm;Integration;High;Sudah menjalankan TC-C02.;1. Inspect stock_allocations setelah transaksi.;Allocation consume terbentuk dengan benar dan tidak ada row allocation yatim atau rusak.
TC-H03;FIFO-v2 dan Integritas Allocation;Stock Allocation;Stock allocation tetap konsisten untuk penjualan telur pooled;Integration;High;Sudah menjalankan TC-F03.;1. Inspect stock_allocations. 2. Inspect row atribusi turunannya.;Allocation mendukung atribusi HPP kembali ke kandang sumber.
TC-H04;FIFO-v2 dan Integritas Allocation;Population Allocation;Population allocation tetap konsisten untuk penjualan ayam pooled;Integration;High;Sudah menjalankan TC-F04.;1. Inspect population allocations.;Penggunaan kandang sumber teralokasi dengan benar dan tidak fallback ke atribusi null saat source tersedia.
TC-I01;Negative dan Guard Cases;Recording;Recording dari stok farm-level dengan qty tidak cukup;Negative;High;Stok farm-level tersedia tetapi qty lebih kecil dari pemakaian yang diinput.;1. Buat recording dengan qty melebihi stok. 2. Submit atau approve.;Muncul validation atau business error dan tidak ada korupsi parsial.
TC-I02;Negative dan Guard Cases;Marketing;Marketing dari stok farm-level dengan qty tidak cukup;Negative;High;Stok farm-level tersedia tetapi qty lebih kecil dari qty penjualan.;1. Buat SO atau DO dengan qty melebihi stok. 2. Submit atau approve.;Delivery atau approval diblok dan stok tetap konsisten.
TC-I03;Negative dan Guard Cases;Frontend Selector;Opsi produk sama di gudang berbeda tidak salah terpilih;UI Regression;Medium;Produk yang sama tersedia di gudang farm dan gudang kandang.;1. Pilih masing-masing opsi secara eksplisit di UI. 2. Save. 3. Buka kembali edit atau detail.;Opsi yang terpilih jelas dan tetap stabil setelah save atau edit.
TC-I04;Negative dan Guard Cases;Product Warehouse;Row gudang shared tidak diatribusikan ulang oleh flow maintenance;Regression;High;Ada row shared farm warehouse yang sudah aktif.;1. Jalankan flow yang menyentuh logic ensure/find product warehouse. 2. Cek ulang row farm shared.;Tidak ada mutasi diam-diam pada project_flock_kandang_id.
TC-J01;Regression Frontend dan UX;Recording Form;Form recording menampilkan opsi stok farm dan kandang hanya dalam scope farm yang sama;UI Regression;Medium;Ada stok di gudang farm, gudang kandang saat ini, dan gudang kandang lain.;1. Buka form recording untuk kandang tertentu. 2. Periksa opsi stock selector.;Gudang farm dan gudang kandang saat ini terlihat, gudang kandang lain tersembunyi.
TC-J02;Regression Frontend dan UX;Recording Form;Selector recording telur mengizinkan gudang farm;UI Regression;Medium;Egg warehouse tersedia di gudang farm.;1. Buka form recording telur. 2. Buka selector tujuan telur.;Gudang farm terlihat sebagai opsi tujuan telur.
TC-J03;Regression Frontend dan UX;Sales Form;Form sales memakai semantik gudang secara konsisten;UI Regression;Medium;Akses ke halaman marketing tersedia.;1. Buka form sales. 2. Periksa label selector dan summary table.;Label menggunakan Gudang Fisik secara konsisten dan tidak ada wording Kandang yang menyesatkan untuk stok fisik.
TC-J04;Regression Frontend dan UX;Marketing Modal;Modal list marketing menampilkan label gudang fisik;UI Regression;Low;Akses ke modal product list tersedia.;1. Buka modal product list di marketing.;Kolom menampilkan label Gudang Fisik.
TC-K01;Known Limitation;Recording Detail;Detail recording belum menampilkan source atau origin attribution baru;Known Limitation;Low;Sudah ada recording telur farm-level dan depletion dengan source attribution.;1. Buat transaksi. 2. Buka detail recording.;Transaksi berjalan dan atribusi tersimpan di DB, tetapi detail API atau UI mungkin belum menampilkan field source atau origin tersebut
1 ID Kategori Area Judul Tipe Prioritas Setup/Precondition Langkah Uji Hasil yang Diharapkan
2 TC-A01 Migrasi dan Keamanan Data Database Migrasi aman pada DB tidak kosong Integration High Gunakan snapshot DB staging yang sudah berisi recording, depletion, telur, penjualan, dan closing. 1. Jalankan migrasi 20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql. 2. Inspect schema hasil migrasi. Kolom recording_depletions.source_project_flock_kandang_id dan recording_eggs.project_flock_kandang_id tersedia dan nullable, index dan FK tersedia, tidak ada data historis yang terhapus atau berubah destruktif.
3 TC-A02 Migrasi dan Keamanan Data Database Backfill deterministik berjalan Integration High Ada data historis recording dengan recordings.project_flock_kandangs_id yang valid. 1. Query recording_depletions dan recording_eggs yang lama. 2. Bandingkan dengan kandang pada parent recording. source_project_flock_kandang_id dan project_flock_kandang_id terisi sama dengan kandang parent recording untuk row yang sebelumnya null.
4 TC-A03 Migrasi dan Keamanan Data Reporting Report historis kandang-only tidak berubah Regression High Gunakan snapshot yang hanya memiliki data stok historis milik kandang, tanpa pooled stock farm-level. 1. Jalankan closing/report/HPP sebelum deploy. 2. Jalankan lagi sesudah deploy pada snapshot yang sama. 3. Bandingkan hasil. Total dan hasil report tetap sama untuk skenario historis kandang-only.
5 TC-B01 Purchase dan Warehouse Purchase Purchase pakan langsung ke gudang farm UAT High Tersedia PO atau purchase request untuk produk Pakan Starter. 1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase. Stok masuk ke product_warehouse level farm, tidak perlu transfer paksa ke kandang, FIFO/HPP purchase tetap benar.
6 TC-B02 Purchase dan Warehouse Purchase Purchase pakan langsung ke gudang kandang Regression High Tersedia PO atau purchase request untuk produk Pakan Starter. 1. Buat purchase ke Gudang Kandang A1. 2. Approve dan receive purchase. Stok masuk ke gudang kandang dan perilaku tetap sama seperti flow lama.
7 TC-B03 Purchase dan Warehouse Purchase Purchase OVK langsung ke gudang farm UAT High Tersedia PO atau purchase request untuk produk OVK A. 1. Buat purchase ke Gudang Farm A. 2. Approve dan receive purchase. Stok OVK masuk ke gudang farm dan bisa dipakai kemudian pada recording.
8 TC-B04 Purchase dan Warehouse Product Warehouse Gudang farm shared tidak diubah diam-diam menjadi milik kandang Regression High Sudah ada row product_warehouse level farm untuk Pakan Starter di Gudang Farm A. 1. Trigger flow yang memanggil ensure/find product warehouse untuk produk yang sama. 2. Inspect row existing. Row farm-level tetap farm-level, project_flock_kandang_id tidak dibackfill diam-diam, row khusus kandang dibuat terpisah bila memang diperlukan.
9 TC-C01 Recording Stock Consumption Recording Recording kandang memakai pakan dari gudang kandang Regression High Stok pakan tersedia di Gudang Kandang A1. 1. Buka recording untuk Kandang A1. 2. Pilih pakan dari gudang kandang. 3. Submit dan approve. Recording berhasil, stok keluar dari product_warehouse kandang, atribusi kandang tetap A1, HPP pemakaian muncul di closing/HPP A1.
10 TC-C02 Recording Stock Consumption Recording Recording kandang memakai pakan dari gudang farm UAT High Stok pakan hanya tersedia di Gudang Farm A. 1. Buka recording untuk Kandang A1. 2. Pilih stok pakan farm-level. 3. Submit dan approve. Recording berhasil tanpa transfer ke kandang, stok fisik berkurang dari gudang farm, usage/HPP tetap teratribusi ke Kandang A1, closing farm dan kandang tetap bisa dihitung.
11 TC-C03 Recording Stock Consumption Recording Recording kandang memakai OVK dari gudang farm UAT High Stok OVK hanya tersedia di Gudang Farm A. 1. Buka recording untuk Kandang A1. 2. Pilih stok OVK farm-level. 3. Submit dan approve. Stok OVK keluar dari gudang farm dan biaya pemakaian teratribusi ke kandang yang dipilih.
12 TC-C04 Recording Stock Consumption Frontend Recording Selector recording menampilkan opsi stok farm dan kandang dengan jelas UI Regression Medium Produk yang sama tersedia di Gudang Farm A dan Gudang Kandang A1. 1. Buka form recording untuk A1. 2. Buka selector pakan. Kedua opsi terlihat, label membedakan gudang atau scope dengan jelas, farm stock tidak tersembunyi secara salah.
13 TC-C05 Recording Stock Consumption Recording Recording A1 tidak boleh memakai stok kandang A2 Negative High Pakan Starter tersedia di Gudang Kandang A2. 1. Buka recording untuk A1. 2. Periksa opsi stok yang bisa dipilih. Opsi Gudang Kandang A2 tidak bisa dipilih, stok farm tetap bisa dipilih.
14 TC-C06 Recording Stock Consumption Recording Perilaku pending stock dan usage lama tetap berjalan Regression Medium Tidak ada setup khusus selain data recording yang valid. 1. Buat usage stock. 2. Buka kembali halaman edit dan detail. Tampilan dan perhitungan pending atau usage tetap benar, tidak ada regresi pada route FIFO-v2.
15 TC-D01 Recording Telur dan Atribusi Recording Recording telur ke gudang kandang tetap berjalan Regression High Kandang A1 aktif dan gudang telur kandang tersedia. 1. Record telur untuk A1 ke Gudang Kandang A1. 2. Approve. Stok telur di gudang kandang bertambah dan asal kandang tetap A1.
16 TC-D02 Recording Telur dan Atribusi Recording Recording telur di kandang menyimpan stok ke gudang farm UAT High Egg product warehouse tersedia di Gudang Farm A. 1. Record telur untuk A1. 2. Pilih Gudang Farm A sebagai gudang telur. 3. Submit dan approve. Stok telur fisik masuk ke gudang farm, recording_eggs.project_flock_kandang_id bernilai A1, tidak ada transfer paksa ke kandang.
17 TC-D03 Recording Telur dan Atribusi Reporting Stok telur pooled di farm tetap punya jejak asal kandang Integration High A1 record 100 telur ke gudang farm dan A2 record 150 telur ke gudang farm yang sama. 1. Inspect row telur yang tersimpan. 2. Inspect hasil costing atau report setelahnya. Stok fisik pooled di gudang farm, tetapi asal kandang tetap bisa dibedakan per row atau allocation, HPP per kandang tetap dapat dihitung.
18 TC-D04 Recording Telur dan Atribusi Recording Detail Known gap pada detail recording dipahami Known Limitation Low Sudah menjalankan TC-D02. 1. Buka detail recording setelah transaksi telur ke gudang farm. Logika bisnis tetap berjalan, tetapi detail API atau UI mungkin belum menampilkan egg-origin secara eksplisit karena detail DTO belum diperluas.
19 TC-E01 Depletion dan Atribusi Populasi Recording Depletion dari gudang ayam milik kandang normal Regression High A1 memiliki populasi ayam di gudang kandang. 1. Buat depletion. 2. Approve. Depletion berhasil, alokasi populasi ter-resolve ke A1, HPP atau usage tetap benar.
20 TC-E02 Depletion dan Atribusi Populasi Recording Depletion dari sumber ayam fisik farm-level dengan source kandang A1 UAT High Stok ayam secara fisik ada di gudang farm dan punya jejak sumber ke A1. 1. Buat depletion untuk A1. 2. Gunakan path source atau farm-level yang didukung backend. 3. Approve. source_product_warehouse_id menunjuk ke sumber fisik yang benar, source_project_flock_kandang_id bernilai A1, alokasi populasi berhasil tanpa mengasumsikan gudang fisik milik A1.
21 TC-E03 Depletion dan Atribusi Populasi Recording Depletion gagal bila sumber populasi tidak dapat diatribusikan Negative High Buat kasus stok ayam farm-level tanpa source kandang yang valid. 1. Coba approve depletion. Backend menolak dengan error yang jelas dan tidak ada silent misattribution.
22 TC-F01 Marketing dan Penjualan Sales Order Sales order dari gudang kandang tetap berjalan Regression High Stok produk tersedia di Gudang Kandang A1. 1. Buat SO dari Gudang Kandang A1. 2. Lakukan delivery. Perilaku lama tetap berjalan normal.
23 TC-F02 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk telur UAT High Stok telur farm-level tersedia dan berasal dari A1. 1. Buat SO menggunakan Gudang Farm A. 2. Lakukan delivery. SO dan DO berhasil, stok fisik berkurang dari gudang farm, HPP dan COGS telur tetap teratribusi ke kandang penghasil melalui allocation.
24 TC-F03 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk telur pooled A1 dan A2 Integration High Stok telur pooled tersedia di gudang farm dari A1 dan A2. 1. Buat penjualan. 2. Lakukan delivery. 3. Inspect closing atau report. Stok fisik berkurang sekali dari gudang farm, revenue dan HPP terbagi benar ke A1 dan A2, tidak bergantung pada pw.project_flock_kandang_id.
25 TC-F04 Marketing dan Penjualan Sales Order Sales order dari gudang farm untuk ayam atau culling UAT High Stok ayam atau culling farm-level tersedia dengan jejak sumber dari A1 dan A2. 1. Buat SO dari gudang farm. 2. Buat DO dan approve. allocatePopulationForMarketingDelivery menurunkan atribusi kandang dari source groups atau allocation, tidak gagal karena gudang jual tidak punya project_flock_kandang_id, HPP dan COGS teratribusi ke kandang sumber.
26 TC-F05 Marketing dan Penjualan Frontend Marketing UI sales menampilkan semantik Gudang Fisik UI Regression Medium Tidak ada setup khusus selain akses ke form SO. 1. Buka form SO. 2. Periksa label selector gudang dan label tabel produk. UI menggunakan label Gudang Fisik, bukan Kandang yang menyesatkan, dan label produk memuat detail produk serta gudang atau scope.
27 TC-F06 Marketing dan Penjualan Delivery Order Layar delivery order tetap kompatibel Regression Medium Sudah ada SO dari gudang farm. 1. Lakukan delivery untuk SO farm-level. 2. Periksa tabel dan detail DO. Tidak ada masalah payload, gudang fisik tampil dengan benar, dan tidak ada kebingungan akibat wording lama berbasis kandang.
28 TC-G01 Report, Closing, dan HPP Daily Marketing Report Daily marketing report untuk penjualan telur farm-level UAT Medium Sudah menjalankan TC-F02. 1. Jalankan daily marketing report. 2. Uji export. Row muncul pada gudang fisik yang benar, report tidak menyiratkan gudang sama dengan kandang, export berjalan.
29 TC-G02 Report, Closing, dan HPP Closing Sales Closing sales untuk penjualan pooled farm-level UAT High Ada penjualan pooled telur atau ayam dari gudang farm. 1. Buka closing sales. Penjualan bisa tampil teratribusi per kandang, label menunjukkan Kandang Atribusi, HPP dan revenue tetap benar secara matematis.
30 TC-G03 Report, Closing, dan HPP HPP per Kandang HPP per kandang mencakup konsumsi pakan atau OVK dari gudang farm UAT High A1 sudah memakai pakan atau OVK dari gudang farm. 1. Jalankan report HPP per kandang. Biaya usage muncul di A1 dan tidak hilang walaupun gudang fisiknya level farm.
31 TC-G04 Report, Closing, dan HPP Closing Sapronak Outgoing sapronak menampilkan gudang fisik dengan benar UI Regression Medium Ada data outgoing sapronak yang valid. 1. Buka tabel closing outgoing sapronak. Header jelas menunjukkan Gudang Asal (Fisik) dan Gudang Tujuan (Fisik).
32 TC-G05 Report, Closing, dan HPP Compatibility Data historis kandang-owned dan pooled data baru dapat coexist Regression High Dalam satu date range ada transaksi lama kandang-owned dan transaksi baru pooled farm-level. 1. Jalankan closing. 2. Jalankan report. 3. Jalankan HPP. Kedua jenis data diproses dengan benar, tidak ada double count dan tidak ada atribusi yang hilang.
33 TC-H01 FIFO-v2 dan Integritas Allocation FIFO-v2 Kontrak FIFO-v2 tidak berubah Integration High Gunakan data uji yang mencakup recording stock, depletion, egg, dan marketing. 1. Verifikasi route FIFO untuk RECORDING_STOCK_OUT, RECORDING_DEPLETION_OUT, RECORDING_DEPLETION_IN, RECORDING_EGG_IN, dan MARKETING_OUT. 2. Bandingkan dengan RFC.md dan seed config FIFO-v2. Tidak ada perubahan semantik route yang tidak disengaja.
34 TC-H02 FIFO-v2 dan Integritas Allocation Stock Allocation Stock allocation tetap konsisten untuk pakan dari gudang farm Integration High Sudah menjalankan TC-C02. 1. Inspect stock_allocations setelah transaksi. Allocation consume terbentuk dengan benar dan tidak ada row allocation yatim atau rusak.
35 TC-H03 FIFO-v2 dan Integritas Allocation Stock Allocation Stock allocation tetap konsisten untuk penjualan telur pooled Integration High Sudah menjalankan TC-F03. 1. Inspect stock_allocations. 2. Inspect row atribusi turunannya. Allocation mendukung atribusi HPP kembali ke kandang sumber.
36 TC-H04 FIFO-v2 dan Integritas Allocation Population Allocation Population allocation tetap konsisten untuk penjualan ayam pooled Integration High Sudah menjalankan TC-F04. 1. Inspect population allocations. Penggunaan kandang sumber teralokasi dengan benar dan tidak fallback ke atribusi null saat source tersedia.
37 TC-I01 Negative dan Guard Cases Recording Recording dari stok farm-level dengan qty tidak cukup Negative High Stok farm-level tersedia tetapi qty lebih kecil dari pemakaian yang diinput. 1. Buat recording dengan qty melebihi stok. 2. Submit atau approve. Muncul validation atau business error dan tidak ada korupsi parsial.
38 TC-I02 Negative dan Guard Cases Marketing Marketing dari stok farm-level dengan qty tidak cukup Negative High Stok farm-level tersedia tetapi qty lebih kecil dari qty penjualan. 1. Buat SO atau DO dengan qty melebihi stok. 2. Submit atau approve. Delivery atau approval diblok dan stok tetap konsisten.
39 TC-I03 Negative dan Guard Cases Frontend Selector Opsi produk sama di gudang berbeda tidak salah terpilih UI Regression Medium Produk yang sama tersedia di gudang farm dan gudang kandang. 1. Pilih masing-masing opsi secara eksplisit di UI. 2. Save. 3. Buka kembali edit atau detail. Opsi yang terpilih jelas dan tetap stabil setelah save atau edit.
40 TC-I04 Negative dan Guard Cases Product Warehouse Row gudang shared tidak diatribusikan ulang oleh flow maintenance Regression High Ada row shared farm warehouse yang sudah aktif. 1. Jalankan flow yang menyentuh logic ensure/find product warehouse. 2. Cek ulang row farm shared. Tidak ada mutasi diam-diam pada project_flock_kandang_id.
41 TC-J01 Regression Frontend dan UX Recording Form Form recording menampilkan opsi stok farm dan kandang hanya dalam scope farm yang sama UI Regression Medium Ada stok di gudang farm, gudang kandang saat ini, dan gudang kandang lain. 1. Buka form recording untuk kandang tertentu. 2. Periksa opsi stock selector. Gudang farm dan gudang kandang saat ini terlihat, gudang kandang lain tersembunyi.
42 TC-J02 Regression Frontend dan UX Recording Form Selector recording telur mengizinkan gudang farm UI Regression Medium Egg warehouse tersedia di gudang farm. 1. Buka form recording telur. 2. Buka selector tujuan telur. Gudang farm terlihat sebagai opsi tujuan telur.
43 TC-J03 Regression Frontend dan UX Sales Form Form sales memakai semantik gudang secara konsisten UI Regression Medium Akses ke halaman marketing tersedia. 1. Buka form sales. 2. Periksa label selector dan summary table. Label menggunakan Gudang Fisik secara konsisten dan tidak ada wording Kandang yang menyesatkan untuk stok fisik.
44 TC-J04 Regression Frontend dan UX Marketing Modal Modal list marketing menampilkan label gudang fisik UI Regression Low Akses ke modal product list tersedia. 1. Buka modal product list di marketing. Kolom menampilkan label Gudang Fisik.
45 TC-K01 Known Limitation Recording Detail Detail recording belum menampilkan source atau origin attribution baru Known Limitation Low Sudah ada recording telur farm-level dan depletion dengan source attribution. 1. Buat transaksi. 2. Buka detail recording. Transaksi berjalan dan atribusi tersimpan di DB, tetapi detail API atau UI mungkin belum menampilkan field source atau origin tersebut
Binary file not shown.
@@ -0,0 +1,343 @@
-- Legacy Egg Cutover Audit Helper Queries
-- Ad-hoc query pack for investigation, audit, dry-run review, and rollback readiness.
-- =====================================================================
-- AUDIT-01 All locations classified by kandang/farm egg posting timing
-- =====================================================================
WITH timing AS (
SELECT
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pk.project_flock_id
JOIN locations l ON l.id = pf.location_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
GROUP BY pf.location_id, l.name
)
SELECT
location_id,
location_name,
first_kandang_date,
last_kandang_date,
first_farm_date,
last_farm_date,
CASE
WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY'
WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER'
ELSE 'OVERLAP'
END AS location_status
FROM timing
ORDER BY location_name;
-- =====================================================================
-- AUDIT-02 All legacy kandang egg product warehouses with positive on-hand
-- =====================================================================
WITH first_farm AS (
SELECT location_id, MIN(id) AS farm_warehouse_id
FROM warehouses
WHERE type = 'LOKASI'
AND deleted_at IS NULL
GROUP BY location_id
)
SELECT
l.id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
p.id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
LEFT JOIN first_farm ff ON ff.location_id = kw.location_id
LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id
WHERE EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
ORDER BY l.name, kw.name, p.name;
-- =====================================================================
-- AUDIT-03 Totals per location for phase sizing
-- =====================================================================
WITH candidates AS (
SELECT
l.name AS location_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
)
SELECT
location_name,
COUNT(*) AS positive_rows,
SUM(on_hand_qty) AS total_on_hand_qty
FROM candidates
GROUP BY location_name
ORDER BY location_name;
-- =====================================================================
-- AUDIT-04 Locations missing farm warehouse
-- =====================================================================
SELECT
l.id AS location_id,
l.name AS location_name
FROM locations l
WHERE EXISTS (
SELECT 1
FROM warehouses kw
WHERE kw.location_id = l.id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1
FROM warehouses fw
WHERE fw.location_id = l.id
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
)
ORDER BY l.name;
-- =====================================================================
-- AUDIT-05 Legacy recording_eggs still pointing to kandang warehouse
-- =====================================================================
SELECT
l.name AS location_name,
kw.name AS kandang_warehouse_name,
p.name AS product_name,
COUNT(*) AS recording_rows
FROM recording_eggs re
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE kw.type = 'KANDANG'
GROUP BY l.name, kw.name, p.name
ORDER BY l.name, kw.name, p.name;
-- =====================================================================
-- AUDIT-06 Farm-level recording_eggs already present
-- =====================================================================
SELECT
l.name AS location_name,
fw.name AS farm_warehouse_name,
p.name AS product_name,
COUNT(*) AS recording_rows
FROM recording_eggs re
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE fw.type = 'LOKASI'
GROUP BY l.name, fw.name, p.name
ORDER BY l.name, fw.name, p.name;
-- =====================================================================
-- AUDIT-07 Transfers created by cutover reason, grouped by run_id
-- =====================================================================
SELECT
SPLIT_PART(SPLIT_PART(st.reason, '|run_id=', 2), '|', 1) AS run_id,
COUNT(DISTINCT st.id) AS transfer_count,
COUNT(std.id) AS detail_count,
SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty,
MIN(st.transfer_date) AS first_transfer_date,
MAX(st.transfer_date) AS last_transfer_date
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=%'
GROUP BY 1
ORDER BY first_transfer_date DESC, run_id DESC;
-- =====================================================================
-- AUDIT-08 Detailed summary per run_id
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
st.transfer_date,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
st.deleted_at
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name;
-- =====================================================================
-- AUDIT-09 Downstream consumption check per run_id
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sa.usable_type,
sa.usable_id,
sa.qty,
sa.function_code,
sa.flag_group_code
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_allocations sa
ON sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.deleted_at IS NULL
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sa.usable_type, sa.usable_id;
-- =====================================================================
-- AUDIT-10 Stock log reconciliation per cutover transfer detail
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
std.id AS transfer_detail_id,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END) AS total_logged_out,
SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END) AS total_logged_in
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
LEFT JOIN stock_logs sl
ON sl.loggable_type = 'TRANSFER'
AND sl.loggable_id = std.id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
GROUP BY st.id, st.movement_number, p.name, std.id, COALESCE(std.total_qty, std.usage_qty, 0)
ORDER BY st.id, p.name;
-- =====================================================================
-- AUDIT-11 New recording eggs still posting to kandang after cutoff date
-- Replace values before running.
-- =====================================================================
SELECT
DATE(r.record_datetime) AS record_date,
l.name AS location_name,
kw.name AS kandang_warehouse_name,
p.name AS product_name,
re.qty
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE kw.type = 'KANDANG'
AND LOWER(l.name) = LOWER('<location_name>')
AND DATE(r.record_datetime) >= DATE('<cutover_date>')
ORDER BY r.record_datetime ASC, kw.name, p.name;
-- Expectation:
-- - after deploy and cutover, this should ideally return 0 rows for the location
-- =====================================================================
-- AUDIT-12 Combined kandang + farm egg stock per location after cutover
-- Replace <location_name> before running.
-- =====================================================================
SELECT
l.name AS location_name,
w.type AS warehouse_type,
p.name AS product_name,
SUM(COALESCE(pw.qty, 0)) AS total_qty
FROM product_warehouses pw
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN locations l ON l.id = w.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
GROUP BY l.name, w.type, p.name
ORDER BY w.type, p.name;
@@ -0,0 +1,400 @@
-- Legacy Egg Cutover Verification Checklist
-- Usage:
-- 1. Replace the values below before executing.
-- 2. Run section BEFORE before --apply.
-- 3. Run section AFTER after --apply.
-- 4. Run rollback checks if needed.
-- =====================================================================
-- PARAMETERS
-- =====================================================================
-- Replace manually before running.
-- Example:
-- location_name = Jamali
-- cutover_date = 2026-04-07
-- run_id = egg-cutover-20260407T130344.220407000Z
-- =====================================================================
-- BEFORE APPLY
-- =====================================================================
-- [BEFORE-01] Identify target location and farm warehouse
SELECT
l.id AS location_id,
l.name AS location_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name
FROM locations l
LEFT JOIN warehouses fw
ON fw.location_id = l.id
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
WHERE LOWER(l.name) = LOWER('<location_name>')
ORDER BY fw.id ASC;
-- Expectation:
-- - exactly one target location
-- - at least one farm warehouse exists
-- [BEFORE-02] Verify location timing status (must be CLEAN_CUTOVER for phase 1)
WITH timing AS (
SELECT
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pk.project_flock_id
JOIN locations l ON l.id = pf.location_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE LOWER(l.name) = LOWER('<location_name>')
GROUP BY pf.location_id, l.name
)
SELECT
location_id,
location_name,
first_kandang_date,
last_kandang_date,
first_farm_date,
last_farm_date,
CASE
WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY'
WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER'
ELSE 'OVERLAP'
END AS location_status
FROM timing;
-- Expectation:
-- - phase 1 location must be CLEAN_CUTOVER
-- [BEFORE-03] Candidate source rows that should be migrated
WITH first_farm AS (
SELECT location_id, MIN(id) AS farm_warehouse_id
FROM warehouses
WHERE type = 'LOKASI'
AND deleted_at IS NULL
GROUP BY location_id
)
SELECT
l.id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
p.id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
LEFT JOIN first_farm ff ON ff.location_id = kw.location_id
LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND EXISTS (
SELECT 1
FROM recording_eggs re
WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
ORDER BY kw.name, p.name;
-- Expectation:
-- - every row here should match dry-run eligible rows
-- [BEFORE-04] Totals per source warehouse and product
WITH candidates AS (
SELECT
kw.name AS source_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
)
SELECT
source_warehouse_name,
product_name,
SUM(on_hand_qty) AS total_qty
FROM candidates
GROUP BY source_warehouse_name, product_name
ORDER BY source_warehouse_name, product_name;
-- [BEFORE-05] Current farm egg stock before cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS farm_on_hand_qty
FROM warehouses fw
JOIN locations l ON l.id = fw.location_id
JOIN product_warehouses pw ON pw.warehouse_id = fw.id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
ORDER BY p.name;
-- [BEFORE-06] Existing cutover transfers for this location
SELECT
st.id,
st.movement_number,
st.transfer_date,
st.reason,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
st.deleted_at
FROM stock_transfers st
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
LEFT JOIN locations l ON l.id = COALESCE(ws.location_id, wd.location_id)
WHERE LOWER(COALESCE(l.name, '')) = LOWER('<location_name>')
AND st.reason LIKE 'EGG_FARM_CUTOVER|%'
ORDER BY st.id DESC;
-- Expectation:
-- - no unexpected older active cutover transfers for the same location
-- =====================================================================
-- AFTER APPLY
-- =====================================================================
-- [AFTER-01] Transfer headers created by run_id
SELECT
st.id,
st.movement_number,
st.transfer_date,
st.reason,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
st.deleted_at
FROM stock_transfers st
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id ASC;
-- [AFTER-02] Transfer detail rows created by run_id
SELECT
st.id AS transfer_id,
st.movement_number,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
std.source_product_warehouse_id,
std.dest_product_warehouse_id
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name;
-- [AFTER-03] Stock logs created by run_id transfer details
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sl.product_warehouse_id,
sl.increase,
sl.decrease,
sl.stock,
sl.created_at
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_logs sl
ON sl.loggable_type = 'TRANSFER'
AND sl.loggable_id = std.id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sl.id;
-- Expectation:
-- - every detail has one stock log decrease from source and one stock log increase to destination
-- [AFTER-04] Source rows after cutover
SELECT
kw.name AS source_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS source_qty_after
FROM product_warehouses pw
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND kw.type = 'KANDANG'
AND EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
ORDER BY kw.name, p.name;
-- Expectation:
-- - rows that were transferred should now be 0 or no longer available for use
-- [AFTER-05] Farm rows after cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS farm_qty_after
FROM product_warehouses pw
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
ORDER BY fw.name, p.name;
-- Expectation:
-- - farm qty increases by the moved amount
-- [AFTER-06] Reconciliation: total moved by run
SELECT
p.name AS product_name,
SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
GROUP BY p.name
ORDER BY p.name;
-- [AFTER-07] Farm stock available for SO after cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS available_qty
FROM product_warehouses pw
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
AND COALESCE(pw.qty, 0) > 0
ORDER BY p.name;
-- =====================================================================
-- ROLLBACK CHECKS
-- =====================================================================
-- [ROLLBACK-01] Check downstream consumption guard before rollback
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sa.usable_type,
sa.usable_id,
sa.qty,
sa.function_code,
sa.flag_group_code
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_allocations sa
ON sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.deleted_at IS NULL
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sa.usable_type, sa.usable_id;
-- Expectation:
-- - rollback only safe if this query returns 0 rows
-- [ROLLBACK-02] Verify run is fully rolled back
SELECT
st.id,
st.movement_number,
st.deleted_at
FROM stock_transfers st
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id;
-- Expectation:
-- - after rollback, deleted_at should be filled for all transfers in the run
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17 -1
View File
@@ -9,15 +9,18 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1
github.com/bytedance/sonic v1.12.1
github.com/glebarez/sqlite v1.11.0
github.com/go-playground/validator/v10 v10.27.0
github.com/gofiber/contrib/jwt v1.0.10
github.com/gofiber/fiber/v2 v2.52.5
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/jackc/pgconn v1.14.1
github.com/jackc/pgx/v5 v5.5.5
github.com/redis/go-redis/v9 v9.14.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.19.0
github.com/xuri/excelize/v2 v2.9.0
golang.org/x/crypto v0.33.0
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.11
@@ -45,8 +48,11 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
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
@@ -56,7 +62,6 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.2 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -68,8 +73,12 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/philhofer/fwd v1.1.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
@@ -78,12 +87,15 @@ require (
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
@@ -94,4 +106,8 @@ require (
golang.org/x/text v0.22.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+38 -1
View File
@@ -65,12 +65,20 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
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=
@@ -88,6 +96,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
@@ -174,6 +184,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
@@ -184,6 +196,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -227,8 +247,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
@@ -241,6 +262,12 @@ github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY=
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@@ -267,6 +294,8 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -344,4 +373,12 @@ gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+94
View File
@@ -0,0 +1,94 @@
package apikeys
func DefaultDashboardPermissions() []string {
return []string{
"lti.approval.list",
"lti.closing.list",
"lti.closing.detail",
"lti.daily_checklist.create",
"lti.daily_checklist.dashboard.list",
"lti.daily_checklist.detail",
"lti.daily_checklist.list",
"lti.daily_checklist.master_data.activity",
"lti.daily_checklist.master_data.configuration",
"lti.daily_checklist.master_data.employee",
"lti.daily_checklist.reports",
"lti.dashboard.list",
"lti.expense.detail",
"lti.expense.list",
"lti.finance.initial_balances.detail",
"lti.finance.injections.detail",
"lti.finance.payments.detail",
"lti.finance.transactions.detail",
"lti.finance.transactions.list",
"lti.inventory.detail",
"lti.inventory.list",
"lti.inventory.product_stock.detail",
"lti.inventory.product_stock.list",
"lti.inventory.product_warehouses.detail",
"lti.inventory.product_warehouses.list",
"lti.inventory.transfer.detail",
"lti.inventory.transfer.list",
"lti.marketing.delivery_order.detail",
"lti.marketing.delivery_order.list",
"lti.master.area.detail",
"lti.master.area.list",
"lti.master.banks.detail",
"lti.master.banks.list",
"lti.master.customer.detail",
"lti.master.customer.list",
"lti.master.fcr.detail",
"lti.master.fcr.list",
"lti.master.flocks.detail",
"lti.master.flocks.list",
"lti.master.kandangs.detail",
"lti.master.kandangs.list",
"lti.master.locations.detail",
"lti.master.locations.list",
"lti.master.nonstocks.detail",
"lti.master.nonstocks.list",
"lti.master.product_categories.detail",
"lti.master.product_categories.list",
"lti.master.products.detail",
"lti.master.products.list",
"lti.master.production_standards.detail",
"lti.master.production_standards.list",
"lti.master.suppliers.detail",
"lti.master.suppliers.list",
"lti.master.uoms.detail",
"lti.master.uoms.list",
"lti.master.warehouses.detail",
"lti.master.warehouses.list",
"lti.production.chickins.detail",
"lti.production.project_flock_kandangs.closing.detail",
"lti.production.project_flock_kandangs.detail",
"lti.production.project_flock_kandangs.list",
"lti.production.project_flocks.detail",
"lti.production.project_flocks.list",
"lti.production.project_flocks.lookup",
"lti.production.project_flocks.next_period",
"lti.production.recording.detail",
"lti.production.recording.list",
"lti.production.recording.next_day",
"lti.production.transfer_to_laying.create",
"lti.production.transfer_to_laying.detail",
"lti.production.transfer_to_laying.getavailableqty",
"lti.production.transfer_to_laying.list",
"lti.production.uniformity.detail",
"lti.production.uniformity.list",
"lti.purchase.detail",
"lti.purchase.list",
"lti.repport.customerpayment.list",
"lti.repport.debtsupplier.list",
"lti.repport.delivery.list",
"lti.repport.expense.list",
"lti.repport.expense.depreciation.manage",
"lti.repport.gethppperkandang.list",
"lti.repport.production_result.list",
"lti.repport.purchasesupplier.list",
"lti.users.detail",
"lti.users.list",
"lti.daily_checklist.master_data.kandang",
"lti.production.chickins.list",
}
}
+107
View File
@@ -0,0 +1,107 @@
package apikeys
import (
"context"
"errors"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type Repository interface {
Create(ctx context.Context, record *entity.IntegrationAPIKey) error
GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error)
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error
TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error
}
type repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &repository{db: db}
}
func (r *repository) Create(ctx context.Context, record *entity.IntegrationAPIKey) error {
if r.db == nil {
return errors.New("database not configured")
}
return r.db.WithContext(ctx).Create(record).Error
}
func (r *repository) GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error) {
if r.db == nil {
return nil, errors.New("database not configured")
}
var record entity.IntegrationAPIKey
if err := r.db.WithContext(ctx).
Where("environment = ?", environment).
Where("key_prefix = ?", prefix).
First(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
func (r *repository) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
if r.db == nil {
return nil, errors.New("database not configured")
}
query := r.db.WithContext(ctx).Model(&entity.IntegrationAPIKey{})
if environment != "" {
query = query.Where("environment = ?", environment)
}
var records []entity.IntegrationAPIKey
if err := query.Order("environment ASC").Order("name ASC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func (r *repository) Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error {
if r.db == nil {
return errors.New("database not configured")
}
updates := map[string]any{
"status": entity.IntegrationAPIKeyStatusRevoked,
"revoked_at": revokedAt,
"updated_at": revokedAt,
}
result := r.db.WithContext(ctx).
Model(&entity.IntegrationAPIKey{}).
Where("environment = ?", environment).
Where("key_prefix = ?", prefix).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *repository) TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error {
if r.db == nil {
return errors.New("database not configured")
}
return r.db.WithContext(ctx).
Model(&entity.IntegrationAPIKey{}).
Where("id = ?", id).
Updates(map[string]any{
"last_used_at": usedAt,
"last_used_from": usedFrom,
"updated_at": usedAt,
}).Error
}
+233
View File
@@ -0,0 +1,233 @@
package apikeys
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/secure"
"gorm.io/gorm"
)
var (
ErrInvalidAPIKey = errors.New("invalid api key")
ErrInactiveKey = errors.New("inactive api key")
)
type Principal struct {
ID uint
Name string
Environment string
Permissions []string
AllArea bool
AreaIDs []uint
AllLocation bool
LocationIDs []uint
}
type Authenticator interface {
Authenticate(ctx context.Context, rawKey, source string) (*Principal, error)
}
type Service interface {
Authenticator
Create(ctx context.Context, input CreateInput) (*IssuedKey, error)
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
Revoke(ctx context.Context, environment, prefix string) error
}
type CreateInput struct {
Name string
Environment string
PermissionCodes []string
AllArea bool
AreaIDs []uint
AllLocation bool
LocationIDs []uint
}
type IssuedKey struct {
Key string
Record *entity.IntegrationAPIKey
}
type service struct {
repo Repository
now func() time.Time
}
func NewService(db *gorm.DB) Service {
return &service{
repo: NewRepository(db),
now: time.Now,
}
}
func (s *service) Authenticate(ctx context.Context, rawKey, source string) (*Principal, error) {
environment, prefix, secret, err := parseRawKey(rawKey)
if err != nil {
return nil, ErrInvalidAPIKey
}
record, err := s.repo.GetByEnvironmentAndPrefix(ctx, environment, prefix)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidAPIKey
}
return nil, err
}
if !strings.EqualFold(record.Status, entity.IntegrationAPIKeyStatusActive) || record.RevokedAt != nil {
return nil, ErrInactiveKey
}
if !secure.Verify(record.KeyHash, secret) {
return nil, ErrInvalidAPIKey
}
usedAt := s.now().UTC()
if err := s.repo.TouchLastUsed(ctx, record.ID, usedAt, strings.TrimSpace(source)); err != nil {
utils.Log.WithError(err).Warn("api key: failed to update last_used fields")
}
return &Principal{
ID: record.ID,
Name: record.Name,
Environment: record.Environment,
Permissions: canonicalPermissions(record.PermissionCodes),
AllArea: record.AllArea,
AreaIDs: uniqueUint(record.AreaIDs),
AllLocation: record.AllLocation,
LocationIDs: uniqueUint(record.LocationIDs),
}, nil
}
func (s *service) Create(ctx context.Context, input CreateInput) (*IssuedKey, error) {
name := strings.TrimSpace(input.Name)
environment := strings.ToLower(strings.TrimSpace(input.Environment))
if name == "" || environment == "" {
return nil, fmt.Errorf("name and environment are required")
}
prefix, err := randomToken(10)
if err != nil {
return nil, err
}
secret, err := randomToken(24)
if err != nil {
return nil, err
}
hash, err := secure.Hash(secret, nil)
if err != nil {
return nil, err
}
record := &entity.IntegrationAPIKey{
Name: name,
Environment: environment,
Status: entity.IntegrationAPIKeyStatusActive,
KeyPrefix: prefix,
KeyHash: hash,
PermissionCodes: canonicalPermissions(input.PermissionCodes),
AllArea: input.AllArea,
AreaIDs: uniqueUint(input.AreaIDs),
AllLocation: input.AllLocation,
LocationIDs: uniqueUint(input.LocationIDs),
}
if err := s.repo.Create(ctx, record); err != nil {
return nil, err
}
return &IssuedKey{
Key: fmt.Sprintf("lti_%s_%s_%s", environment, prefix, secret),
Record: record,
}, nil
}
func (s *service) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
return s.repo.List(ctx, strings.ToLower(strings.TrimSpace(environment)))
}
func (s *service) Revoke(ctx context.Context, environment, prefix string) error {
environment = strings.ToLower(strings.TrimSpace(environment))
prefix = strings.TrimSpace(prefix)
if environment == "" || prefix == "" {
return fmt.Errorf("environment and prefix are required")
}
return s.repo.Revoke(ctx, environment, prefix, s.now().UTC())
}
func parseRawKey(rawKey string) (environment string, prefix string, secret string, err error) {
rawKey = strings.TrimSpace(rawKey)
parts := strings.Split(rawKey, "_")
if len(parts) != 4 || parts[0] != "lti" {
return "", "", "", ErrInvalidAPIKey
}
environment = strings.ToLower(strings.TrimSpace(parts[1]))
prefix = strings.TrimSpace(parts[2])
secret = strings.TrimSpace(parts[3])
if environment == "" || prefix == "" || secret == "" {
return "", "", "", ErrInvalidAPIKey
}
return environment, prefix, secret, nil
}
func randomToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
return strings.ToLower(encoder.EncodeToString(buf)), nil
}
func canonicalPermissions(perms []string) []string {
if len(perms) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(perms))
result := make([]string, 0, len(perms))
for _, perm := range perms {
perm = strings.ToLower(strings.TrimSpace(perm))
if perm == "" {
continue
}
if _, ok := seen[perm]; ok {
continue
}
seen[perm] = struct{}{}
result = append(result, perm)
}
return result
}
func uniqueUint(values []uint) []uint {
if len(values) == 0 {
return []uint{}
}
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
-44
View File
@@ -1,44 +0,0 @@
package capabilities
import (
"strings"
recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings"
)
// FromPermissions returns a filtered map of capabilities that the frontend can use
// to toggle features. Only permissions recognized by the application are exposed.
func FromPermissions(perms []string) map[string]bool {
if len(perms) == 0 {
return nil
}
out := make(map[string]bool)
for _, perm := range perms {
if key, ok := normalizeAndAllow(perm); ok {
out[key] = true
}
}
if len(out) == 0 {
return nil
}
return out
}
func normalizeAndAllow(perm string) (string, bool) {
perm = strings.ToLower(strings.TrimSpace(perm))
if perm == "" {
return "", false
}
if _, ok := allowed[perm]; !ok {
return "", false
}
return perm, true
}
var allowed = map[string]struct{}{
recordings.PermissionRecordingRead: {},
recordings.PermissionRecordingCreate: {},
recordings.PermissionRecordingUpdate: {},
recordings.PermissionRecordingDelete: {},
}
+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)
}
}
@@ -84,8 +84,9 @@ func (r *approvalRepositoryImpl) LatestByTargets(
result := make(map[uint]entity.Approval, len(approvableIDs))
q := r.DB().WithContext(ctx).
Select("DISTINCT ON (approvable_id) *").
Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs).
Order("action_at DESC")
Order("approvable_id, action_at DESC")
if modifier != nil {
q = modifier(q)
@@ -187,10 +187,11 @@ func (r *BaseRepositoryImpl[T]) PatchOne(
updates map[string]any,
modifier func(*gorm.DB) *gorm.DB,
) error {
q := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id)
q := r.db.WithContext(ctx)
if modifier != nil {
q = modifier(q)
}
q = q.Model(new(T)).Where("id = ?", id)
result := q.Updates(updates)
if result.Error != nil {
@@ -2,6 +2,7 @@ package repository
import (
"context"
"errors"
"fmt"
"gorm.io/gorm"
@@ -9,45 +10,59 @@ import (
// Exists reports whether a record with the given ID exists for type T.
func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) {
var count int64
if err := db.WithContext(ctx).
var marker int
err := db.WithContext(ctx).
Model(new(T)).
Select("1").
Where("id = ?", id).
Count(&count).Error; err != nil {
Limit(1).
Take(&marker).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return count > 0, nil
return true, nil
}
func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) {
var count int64
q := db.WithContext(ctx).
Model(new(T)).
Select("1").
Where("name = ?", name).
Where("deleted_at IS NULL")
if excludeID != nil {
q = q.Where("id <> ?", *excludeID)
}
if err := q.Count(&count).Error; err != nil {
var marker int
if err := q.Limit(1).Take(&marker).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return count > 0, nil
return true, nil
}
func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) {
if field == "" {
return false, fmt.Errorf("field is required")
}
var count int64
q := db.WithContext(ctx).
Model(new(T)).
Select("1").
Where(fmt.Sprintf("%s = ?", field), value).
Where("deleted_at IS NULL")
if excludeID != nil {
q = q.Where("id <> ?", *excludeID)
}
if err := q.Count(&count).Error; err != nil {
var marker int
if err := q.Limit(1).Take(&marker).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return count > 0, nil
return true, nil
}
@@ -0,0 +1,429 @@
package repository
import (
"context"
"errors"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type HppCostRepository interface {
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error)
GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error)
GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error)
}
type HppRepositoryImpl struct {
db *gorm.DB
}
func NewHppCostRepository(db *gorm.DB) HppCostRepository {
return &HppRepositoryImpl{db: db}
}
func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) {
var ids []uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Select("id").
Where("project_flock_id = ?", projectFlockId).
Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableAdjustment,
).
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
usableProjectChickin,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeTraceChickin,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_budgets AS pb").
Select("COALESCE(SUM(pb.qty * pb.price), 0)").
Where("pb.project_flock_id = ?", projectFlockId).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("expense_nonstocks AS en").
Select("COALESCE(SUM(er.qty * er.price), 0)").
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
// Where("f.name = ?", utils.FlagEkspedisi).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableAdjustment,
).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
usableRecordingStock,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
flags := []utils.FlagType{
utils.FlagOVK,
utils.FlagObat,
utils.FlagVitamin,
utils.FlagKimia,
}
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableAdjustment,
).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN 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 = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
usableRecordingStock,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty), 0)").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableTransferIn,
stockableAdjustment,
).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
if date == nil {
now := time.Now()
date = &now
}
var totals struct {
TotalPieces float64
TotalWeightKg float64
}
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg").
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
var adjustmentTotalWeight float64
adjustmentSubQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select("DISTINCT ast.id AS adjustment_id, ast.price AS price").
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id").
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = std.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyStockTransferOut.String(),
fifo.StockableKeyAdjustmentIn.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND ast.product_warehouse_id = std.source_product_warehouse_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date)
err = r.db.WithContext(ctx).
Table("(?) AS adjustment_sources", adjustmentSubQuery).
Select("COALESCE(SUM(adjustment_sources.price), 0)").
Scan(&adjustmentTotalWeight).Error
if err != nil {
return 0, 0, err
}
totals.TotalWeightKg += adjustmentTotalWeight
return totals.TotalPieces, totals.TotalWeightKg, nil
}
func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
ctx context.Context,
projectFlockKandangIDs []uint,
startDate *time.Time,
endDate *time.Time,
) (float64, float64, error) {
if endDate == nil {
now := time.Now()
endDate = &now
}
type subResult struct {
UsableID uint
MdpUsageQty float64
MdpWeight float64
}
subQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
DISTINCT sa.usable_id,
mdp.usage_qty AS mdp_usage_qty,
mdp.total_weight AS mdp_weight
`).
Joins("JOIN recording_eggs re ON re.recording_id = r.id").
Joins(
"JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *endDate).
Where("mdp.delivery_date <= ?", *startDate)
var totals struct {
TotalPieces float64
TotalWeight float64
}
err := r.db.WithContext(ctx).
Table("(?) AS x", subQuery).
Select(`
COALESCE(SUM(x.mdp_usage_qty), 0) AS total_pieces,
COALESCE(SUM(x.mdp_weight), 0) AS total_weight
`).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
return totals.TotalPieces, totals.TotalWeight, nil
}
func (r *HppRepositoryImpl) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) {
var projectFlockID uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Select("project_flock_id").
Where("id = ?", projectFlockKandangId).
Scan(&projectFlockID).Error
if err != nil {
return 0, err
}
return projectFlockID, nil
}
func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) {
var summary struct {
ProjectFlockID uint
TotalQty float64
}
err := r.db.WithContext(ctx).
Table("laying_transfer_targets AS ltt").
Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty").
Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id").
Where("lt.deleted_at IS NULL").
Where("ltt.deleted_at IS NULL").
Where("lt.executed_at IS NOT NULL").
Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId).
Group("lt.from_project_flock_id").
Scan(&summary).Error
if err != nil {
return 0, 0, err
}
return summary.ProjectFlockID, summary.TotalQty, nil
}
func (r *HppRepositoryImpl) GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error) {
type row struct {
TotalCost float64
}
var selected row
err := r.db.WithContext(ctx).
Table("farm_depreciation_manual_inputs").
Select("total_cost").
Where("project_flock_id = ?", projectFlockId).
Limit(1).
Take(&selected).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return selected.TotalCost, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,390 @@
package repository
import (
"context"
"fmt"
"math"
"testing"
"time"
"github.com/glebarez/sqlite"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
func TestHppV2RepositoryGetEggProduksiIncludesTransferredAdjustmentStock(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
mustExecHppV2(t, db,
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime) VALUES (1, 101, '2026-04-19 10:00:00')`,
`INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 401, 80, 8, 101)`,
`INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00')`,
`INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 301, 401)`,
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose) VALUES (1, 'STOCK_TRANSFER_OUT', 1, 'ADJUSTMENT_IN', 501, 'ACTIVE', 'CONSUME')`,
`INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, price, created_at) VALUES (501, 301, 20, 2.5, '2026-04-18 07:30:00')`,
)
repo := &HppV2RepositoryImpl{db: db}
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
totalPieces, totalWeightKg, err := repo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &endDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, totalPieces, 100)
assertFloatEquals(t, totalWeightKg, 10.5)
}
func TestHppV2RepositoryGetEggTerjualUsesEndDateForSameDayFarmSales(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
mustExecHppV2(t, db,
`INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`,
`INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`,
`INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10)`,
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL)`,
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`,
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES (1, 101, '2026-04-19 08:00:00', NULL), (2, 102, '2026-04-19 09:00:00', NULL)`,
`INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 301, 60, 6, 101), (2, 2, 301, 40, 4, 102)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 50, 5, '2026-04-19 12:00:00')`,
)
repo := &HppV2RepositoryImpl{db: db}
startDate := mustJakartaTime(t, "2026-04-19 00:00:00")
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, totalPieces, 30)
assertFloatEquals(t, totalWeightKg, 3)
}
func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
mustExecHppV2(t, db,
`INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`,
`INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`,
`INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10), (211, 'KANDANG', 10), (212, 'KANDANG', 10)`,
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL), (311, 211, 900, 101), (312, 212, 900, 102)`,
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`,
`INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00'), (2, '2026-04-18 08:15:00')`,
`INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 311, 301), (2, 2, 312, 301)`,
`INSERT INTO adjustment_stocks (id, product_warehouse_id, usage_qty, price, function_code, transaction_type, created_at) VALUES
(801, 311, 70, 7, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:00:00'),
(802, 312, 30, 3, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:30:00')`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 20, 2, '2026-04-19 12:00:00')`,
)
repo := &HppV2RepositoryImpl{db: db}
startDate := mustJakartaTime(t, "2026-04-19 00:00:00")
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, totalPieces, 14)
assertFloatEquals(t, totalWeightKg, 1.4)
}
func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
approvalType := utils.ApprovalWorkflowTransferToLaying.String()
mustExecHppV2(t, db,
`INSERT INTO project_flock_kandangs (id, kandang_id, project_flock_id) VALUES
(101, 1, 1),
(102, 2, 1),
(103, 3, 1),
(104, 4, 1),
(105, 5, 1),
(201, 6, 2)`,
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES
(1, 101, '2026-04-10 08:00:00', NULL),
(2, 101, '2026-04-10 08:05:00', NULL),
(3, 101, '2026-04-10 08:10:00', NULL),
(4, 102, '2026-04-10 08:15:00', NULL),
(5, 102, '2026-04-10 08:20:00', NULL),
(6, 103, '2026-04-12 08:00:00', NULL),
(7, 103, '2026-04-12 08:05:00', NULL),
(8, 104, '2026-04-12 08:10:00', NULL),
(9, 104, '2026-04-12 08:15:00', NULL),
(10, 105, '2026-04-12 08:20:00', NULL),
(11, 105, '2026-04-12 08:25:00', NULL)`,
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES
(501, 201, 10, NULL),
(502, 201, 10, NULL),
(503, 201, 10, NULL),
(504, 201, 10, NULL),
(505, 201, 10, NULL),
(506, 201, 10, NULL),
(507, 201, 10, NULL),
(508, 201, 10, NULL),
(509, 201, 10, NULL),
(510, 201, 10, NULL),
(511, 201, 10, NULL)`,
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES
(10, 'products', 10, 'PAKAN')`,
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, project_flock_kandang_id) VALUES
(101, 1, 501, NULL),
(102, 2, 502, 201),
(103, 3, 503, 101),
(104, 4, 504, NULL),
(105, 5, 505, 201),
(106, 6, 506, NULL),
(107, 7, 507, 201),
(108, 8, 508, NULL),
(109, 9, 509, 201),
(110, 10, 510, NULL),
(111, 11, 511, 201)`,
`INSERT INTO purchase_items (id, product_id, price) VALUES
(601, 10, 100),
(602, 10, 110),
(603, 10, 120),
(604, 10, 130),
(605, 10, 140),
(606, 10, 150),
(607, 10, 160),
(608, 10, 170),
(609, 10, 180),
(610, 10, 190),
(611, 10, 200)`,
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose, qty) VALUES
(9001, 'RECORDING_STOCK', 101, 'PURCHASE_ITEMS', 601, 'ACTIVE', 'CONSUME', 2),
(9002, 'RECORDING_STOCK', 102, 'PURCHASE_ITEMS', 602, 'ACTIVE', 'CONSUME', 1),
(9003, 'RECORDING_STOCK', 103, 'PURCHASE_ITEMS', 603, 'ACTIVE', 'CONSUME', 1),
(9004, 'RECORDING_STOCK', 104, 'PURCHASE_ITEMS', 604, 'ACTIVE', 'CONSUME', 1),
(9005, 'RECORDING_STOCK', 105, 'PURCHASE_ITEMS', 605, 'ACTIVE', 'CONSUME', 1),
(9006, 'RECORDING_STOCK', 106, 'PURCHASE_ITEMS', 606, 'ACTIVE', 'CONSUME', 1),
(9007, 'RECORDING_STOCK', 107, 'PURCHASE_ITEMS', 607, 'ACTIVE', 'CONSUME', 1),
(9008, 'RECORDING_STOCK', 108, 'PURCHASE_ITEMS', 608, 'ACTIVE', 'CONSUME', 1),
(9009, 'RECORDING_STOCK', 109, 'PURCHASE_ITEMS', 609, 'ACTIVE', 'CONSUME', 1),
(9010, 'RECORDING_STOCK', 110, 'PURCHASE_ITEMS', 610, 'ACTIVE', 'CONSUME', 1),
(9011, 'RECORDING_STOCK', 111, 'PURCHASE_ITEMS', 611, 'ACTIVE', 'CONSUME', 1)`,
`INSERT INTO laying_transfers (id, transfer_date, effective_move_date, economic_cutoff_date, executed_at, deleted_at) VALUES
(1001, '2026-04-04', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL),
(1002, '2026-05-01', '2026-05-01', NULL, '2026-05-01 00:00:00', NULL),
(1003, '2026-04-03', '2026-04-05', NULL, '2026-04-05 00:00:00', NULL),
(1004, '2026-04-03', '2026-04-05', NULL, NULL, NULL)`,
`INSERT INTO laying_transfer_targets (id, laying_transfer_id, target_project_flock_kandang_id, deleted_at) VALUES
(2001, 1001, 101, NULL),
(2002, 1002, 103, NULL),
(2003, 1003, 104, NULL),
(2004, 1004, 105, NULL)`,
fmt.Sprintf(`INSERT INTO approvals (id, approvable_type, approvable_id, action) VALUES
(3001, '%s', 1001, 'APPROVED'),
(3002, '%s', 1002, 'APPROVED'),
(3003, '%s', 1003, 'APPROVED'),
(3004, '%s', 1003, 'REJECTED'),
(3005, '%s', 1004, 'APPROVED')`,
approvalType, approvalType, approvalType, approvalType, approvalType),
)
repo := &HppV2RepositoryImpl{db: db}
periodDate := mustJakartaTime(t, "2026-04-30 00:00:00")
total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, total, 750)
earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59")
earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, earlyTotal, 240)
}
func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
mustExecHppV2(t, db,
`CREATE TABLE recordings (
id INTEGER PRIMARY KEY,
project_flock_kandangs_id INTEGER NULL,
record_datetime DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE recording_stocks (
id INTEGER PRIMARY KEY,
recording_id INTEGER NULL,
product_warehouse_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE recording_eggs (
id INTEGER PRIMARY KEY,
recording_id INTEGER NULL,
product_warehouse_id INTEGER NULL,
qty NUMERIC(15,3) NULL,
weight NUMERIC(15,3) NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE stock_transfers (
id INTEGER PRIMARY KEY,
transfer_date DATETIME NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY,
stock_transfer_id INTEGER NULL,
source_product_warehouse_id INTEGER NULL,
dest_product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
usable_type TEXT NULL,
usable_id INTEGER NULL,
stockable_type TEXT NULL,
stockable_id INTEGER NULL,
status TEXT NULL,
allocation_purpose TEXT NULL,
qty NUMERIC(15,3) NULL
)`,
`CREATE TABLE adjustment_stocks (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NULL,
total_qty NUMERIC(15,3) NULL,
usage_qty NUMERIC(15,3) NULL,
price NUMERIC(15,3) NULL,
grand_total NUMERIC(15,3) NULL,
function_code TEXT NULL,
transaction_type TEXT NULL,
created_at DATETIME NULL
)`,
`CREATE TABLE kandangs (
id INTEGER PRIMARY KEY,
location_id INTEGER NULL
)`,
`CREATE TABLE project_flock_kandangs (
id INTEGER PRIMARY KEY,
kandang_id INTEGER NULL,
project_flock_id INTEGER NULL
)`,
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY,
type TEXT NULL,
location_id INTEGER NULL
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
warehouse_id INTEGER NULL,
product_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE marketing_products (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE purchase_items (
id INTEGER PRIMARY KEY,
product_id INTEGER NULL,
price NUMERIC(15,3) NULL
)`,
`CREATE TABLE marketing_delivery_products (
id INTEGER PRIMARY KEY,
marketing_product_id INTEGER NULL,
usage_qty NUMERIC(15,3) NULL,
total_weight NUMERIC(15,3) NULL,
delivery_date DATETIME NULL
)`,
`CREATE TABLE flags (
id INTEGER PRIMARY KEY,
flagable_type TEXT NULL,
flagable_id INTEGER NULL,
name TEXT NULL
)`,
`CREATE TABLE laying_transfers (
id INTEGER PRIMARY KEY,
transfer_date DATETIME NULL,
effective_move_date DATETIME NULL,
economic_cutoff_date DATETIME NULL,
executed_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE laying_transfer_targets (
id INTEGER PRIMARY KEY,
laying_transfer_id INTEGER NULL,
target_project_flock_kandang_id INTEGER NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE approvals (
id INTEGER PRIMARY KEY,
approvable_type TEXT NULL,
approvable_id INTEGER NULL,
action TEXT NULL
)`,
)
return db
}
func mustExecHppV2(t *testing.T, db *gorm.DB, statements ...string) {
t.Helper()
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("failed executing statement %q: %v", statement, err)
}
}
}
func mustJakartaTime(t *testing.T, raw string) time.Time {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading timezone: %v", err)
}
value, err := time.ParseInLocation("2006-01-02 15:04:05", raw, location)
if err != nil {
t.Fatalf("failed parsing time %q: %v", raw, err)
}
return value
}
func assertFloatEquals(t *testing.T, got float64, want float64) {
t.Helper()
if math.Abs(got-want) > 0.000001 {
t.Fatalf("expected %.6f, got %.6f", want, got)
}
}
func TestHppV2RepositoryConstantsStayAlignedWithProductionQueries(t *testing.T) {
if fifo.UsableKeyStockTransferOut.String() != "STOCK_TRANSFER_OUT" {
t.Fatalf("unexpected stock transfer usable key: %s", fifo.UsableKeyStockTransferOut.String())
}
if fifo.StockableKeyAdjustmentIn.String() != "ADJUSTMENT_IN" {
t.Fatalf("unexpected adjustment stockable key: %s", fifo.StockableKeyAdjustmentIn.String())
}
if entity.StockAllocationStatusActive != "ACTIVE" {
t.Fatalf("unexpected active stock allocation status: %s", entity.StockAllocationStatusActive)
}
if entity.StockAllocationPurposeConsume != "CONSUME" {
t.Fatalf("unexpected consume stock allocation purpose: %s", entity.StockAllocationPurposeConsume)
}
if string(utils.AdjustmentTransactionSubtypeRecordingEggIn) != "RECORDING_EGG_IN" {
t.Fatalf("unexpected adjustment function code: %s", utils.AdjustmentTransactionSubtypeRecordingEggIn)
}
if string(utils.AdjustmentTransactionTypeRecording) != "RECORDING" {
t.Fatalf("unexpected adjustment transaction type: %s", utils.AdjustmentTransactionTypeRecording)
}
}
@@ -33,7 +33,7 @@ func (r *StockAllocationRepositoryImpl) FindActiveByUsable(
var allocations []entity.StockAllocation
q := r.DB().WithContext(ctx).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume)
if modifier != nil {
q = modifier(q)
@@ -63,13 +63,14 @@ func (r *StockAllocationRepositoryImpl) ReleaseByUsable(
updates["note"] = *note
}
q := r.DB().WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
baseDB := r.DB()
if modifier != nil {
q = modifier(q)
baseDB = modifier(baseDB)
}
q := baseDB.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume)
return q.Updates(updates).Error
}
@@ -0,0 +1,224 @@
package repository
import (
"fmt"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type MarketingDeliveryAttributionRow struct {
MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"`
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
ProjectFlockID uint `gorm:"column:project_flock_id"`
ProjectFlockCategory string `gorm:"column:project_flock_category"`
AllocatedQty float64 `gorm:"column:allocated_qty"`
}
func MarketingDeliveryAttributionRowsQuery(db *gorm.DB) *gorm.DB {
sql := `
WITH mapped AS (
SELECT
sa.usable_id AS marketing_delivery_product_id,
pc.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN project_flock_populations pfp
ON pfp.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, pc.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN recording_eggs re
ON re.id = sa.stockable_id
AND sa.stockable_type = ?
LEFT JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN recording_depletions rd
ON rd.id = sa.stockable_id
AND sa.stockable_type = ?
LEFT JOIN recordings r ON r.id = rd.recording_id
JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
pi.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN purchase_items pi
ON pi.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_flock_kandangs pfk ON pfk.id = pi.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND pi.project_flock_kandang_id IS NOT NULL
GROUP BY sa.usable_id, pi.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
source_pw.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN stock_transfer_details std
ON std.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id
JOIN project_flock_kandangs pfk ON pfk.id = source_pw.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
AND source_pw.project_flock_kandang_id IS NOT NULL
GROUP BY sa.usable_id, source_pw.project_flock_kandang_id, pfk.project_flock_id, pf.category
UNION ALL
SELECT
sa.usable_id AS marketing_delivery_product_id,
ltt.target_project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
SUM(sa.qty) AS allocated_qty
FROM stock_allocations sa
JOIN laying_transfer_targets ltt
ON ltt.id = sa.stockable_id
AND sa.stockable_type = ?
JOIN project_flock_kandangs pfk ON pfk.id = ltt.target_project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
WHERE sa.usable_type = ?
AND sa.status = ?
AND sa.allocation_purpose = ?
GROUP BY sa.usable_id, ltt.target_project_flock_kandang_id, pfk.project_flock_id, pf.category
)
SELECT
src.marketing_delivery_product_id,
src.project_flock_kandang_id,
src.project_flock_id,
src.project_flock_category,
SUM(src.allocated_qty) AS allocated_qty
FROM (
SELECT
mapped.marketing_delivery_product_id,
mapped.project_flock_kandang_id,
mapped.project_flock_id,
mapped.project_flock_category,
mapped.allocated_qty
FROM mapped
UNION ALL
SELECT
mdp.id AS marketing_delivery_product_id,
pw.project_flock_kandang_id AS project_flock_kandang_id,
pfk.project_flock_id AS project_flock_id,
pf.category AS project_flock_category,
COALESCE(mdp.usage_qty, 0) AS allocated_qty
FROM marketing_delivery_products mdp
JOIN marketing_products mp ON mp.id = mdp.marketing_product_id
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
LEFT JOIN mapped ON mapped.marketing_delivery_product_id = mdp.id
WHERE mapped.marketing_delivery_product_id IS NULL
AND pw.project_flock_kandang_id IS NOT NULL
AND COALESCE(mdp.usage_qty, 0) > 0
) src
GROUP BY
src.marketing_delivery_product_id,
src.project_flock_kandang_id,
src.project_flock_id,
src.project_flock_category
`
return db.Raw(
sql,
fifo.StockableKeyProjectFlockPopulation.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyRecordingDepletion.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyPurchaseItems.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyStockTransferIn.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyTransferToLayingIn.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
)
}
func MarketingDeliverySingleAttributionQuery(db *gorm.DB) *gorm.DB {
return db.
Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)).
Select(`
mda.marketing_delivery_product_id,
CASE
WHEN COUNT(DISTINCT mda.project_flock_kandang_id) = 1 THEN MIN(mda.project_flock_kandang_id)
ELSE NULL
END AS attributed_project_flock_kandang_id
`).
Group("mda.marketing_delivery_product_id")
}
func MarketingDeliveryAttributionFilterSQL(column string) string {
return fmt.Sprintf("EXISTS (SELECT 1 FROM (?) AS mda WHERE mda.marketing_delivery_product_id = %s)", column)
}
@@ -0,0 +1,147 @@
package repository
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestMarketingDeliveryAttributionRowsQueryIncludesMappedAndFallbackRows(t *testing.T) {
db := setupMarketingAttributionTestDB(t)
statements := []string{
`INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`,
`INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`,
`INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`,
`INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`,
`INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`,
`INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`,
`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES
(1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'),
(2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'),
(3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding fixtures: %v", err)
}
}
var rows []MarketingDeliveryAttributionRow
if err := db.Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)).
Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC").
Scan(&rows).Error; err != nil {
t.Fatalf("failed scanning attribution rows: %v", err)
}
if len(rows) != 4 {
t.Fatalf("expected 4 attribution rows, got %d", len(rows))
}
if rows[0].MarketingDeliveryProductID != 601 || rows[0].ProjectFlockKandangID != 101 || rows[0].AllocatedQty != 60 {
t.Fatalf("unexpected first attribution row: %+v", rows[0])
}
if rows[1].MarketingDeliveryProductID != 601 || rows[1].ProjectFlockKandangID != 102 || rows[1].AllocatedQty != 40 {
t.Fatalf("unexpected second attribution row: %+v", rows[1])
}
if rows[2].MarketingDeliveryProductID != 602 || rows[2].ProjectFlockKandangID != 101 || rows[2].AllocatedQty != 25 {
t.Fatalf("unexpected fallback attribution row: %+v", rows[2])
}
if rows[3].MarketingDeliveryProductID != 603 || rows[3].ProjectFlockKandangID != 101 || rows[3].AllocatedQty != 12 {
t.Fatalf("unexpected egg attribution row: %+v", rows[3])
}
}
func TestMarketingDeliverySingleAttributionQueryOnlyReturnsSingleSourceRows(t *testing.T) {
db := setupMarketingAttributionTestDB(t)
statements := []string{
`INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`,
`INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`,
`INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`,
`INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`,
`INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`,
`INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`,
`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES
(1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'),
(2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'),
(3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed seeding fixtures: %v", err)
}
}
type singleRow struct {
MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"`
AttributedProjectFlockKandangID *uint `gorm:"column:attributed_project_flock_kandang_id"`
}
var rows []singleRow
if err := db.Table("(?) AS mda", MarketingDeliverySingleAttributionQuery(db)).
Order("mda.marketing_delivery_product_id ASC").
Scan(&rows).Error; err != nil {
t.Fatalf("failed scanning single attribution rows: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
if rows[0].MarketingDeliveryProductID != 601 || rows[0].AttributedProjectFlockKandangID != nil {
t.Fatalf("expected pooled delivery 601 to have nil single attribution, got %+v", rows[0])
}
if rows[1].MarketingDeliveryProductID != 602 || rows[1].AttributedProjectFlockKandangID == nil || *rows[1].AttributedProjectFlockKandangID != 101 {
t.Fatalf("expected fallback delivery 602 to map to kandang 101, got %+v", rows[1])
}
if rows[2].MarketingDeliveryProductID != 603 || rows[2].AttributedProjectFlockKandangID == nil || *rows[2].AttributedProjectFlockKandangID != 101 {
t.Fatalf("expected egg delivery 603 to map to kandang 101, got %+v", rows[2])
}
}
func setupMarketingAttributionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER,
stockable_type TEXT,
stockable_id INTEGER,
usable_type TEXT,
usable_id INTEGER,
qty NUMERIC(15,3),
status TEXT,
allocation_purpose TEXT
)`,
`CREATE TABLE project_flock_populations (id INTEGER PRIMARY KEY, project_chickin_id INTEGER)`,
`CREATE TABLE project_chickins (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER)`,
`CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER)`,
`CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, category TEXT)`,
`CREATE TABLE marketing_delivery_products (id INTEGER PRIMARY KEY, marketing_product_id INTEGER, usage_qty NUMERIC(15,3))`,
`CREATE TABLE marketing_products (id INTEGER PRIMARY KEY, product_warehouse_id INTEGER)`,
`CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE recording_eggs (id INTEGER PRIMARY KEY, recording_id INTEGER, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE recordings (id INTEGER PRIMARY KEY, project_flock_kandangs_id INTEGER NULL)`,
`CREATE TABLE recording_depletions (id INTEGER PRIMARY KEY, recording_id INTEGER, source_project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE purchase_items (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`,
`CREATE TABLE stock_transfer_details (id INTEGER PRIMARY KEY, source_product_warehouse_id INTEGER NULL)`,
`CREATE TABLE laying_transfer_targets (id INTEGER PRIMARY KEY, target_project_flock_kandang_id INTEGER NULL)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
@@ -15,7 +15,7 @@ type ApprovalService interface {
WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string
WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool)
CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error)
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string) ([]entity.Approval, int64, error)
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string, orderByDate string) ([]entity.Approval, int64, error)
ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error)
LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error)
LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error)
@@ -70,9 +70,14 @@ func (s *approvalService) List(
approvableID *uint,
page, limit int,
search string,
orderByDate string,
) ([]entity.Approval, int64, error) {
module = strings.TrimSpace(strings.ToUpper(module))
search = strings.TrimSpace(search)
orderByDate = strings.TrimSpace(strings.ToUpper(orderByDate))
if orderByDate != "ASC" && orderByDate != "DESC" {
orderByDate = "DESC"
}
if limit <= 0 {
limit = 10
@@ -90,7 +95,7 @@ func (s *approvalService) List(
func(db *gorm.DB) *gorm.DB {
query := db.
Where("approvable_type = ?", module).
Order("action_at DESC").
Order("action_at " + orderByDate).
Preload("ActionUser")
if approvableID != nil {
@@ -0,0 +1,120 @@
package service
import (
"context"
"errors"
"fmt"
productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Dipakai untuk semua module yang butuh cek:
// "PW ini → warehouse → kandang → project_flock_kandang sudah closing atau belum"
func EnsureProjectFlockNotClosedForProductWarehouses(
ctx context.Context,
db *gorm.DB,
productWarehouseIDs []uint,
) error {
if len(productWarehouseIDs) == 0 {
return nil
}
pwRepo := productWarehouseRepo.NewProductWarehouseRepository(db)
wRepo := warehouseRepo.NewWarehouseRepository(db)
pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
seenPW := make(map[uint]struct{})
seenKandang := make(map[uint]struct{})
for _, pwID := range productWarehouseIDs {
if pwID == 0 {
continue
}
if _, ok := seenPW[pwID]; ok {
continue
}
seenPW[pwID] = struct{}{}
pw, err := pwRepo.GetByID(ctx, pwID, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Product warehouse %d tidak ditemukan", pwID))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse")
}
wh, err := wRepo.GetByID(ctx, uint(pw.WarehouseId), nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Warehouse %d tidak ditemukan", pw.WarehouseId))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse")
}
// Warehouse tanpa kandang → bukan kandang produksi → skip
if wh.KandangId == nil || *wh.KandangId == 0 {
continue
}
kandangID := uint(*wh.KandangId)
if _, ok := seenKandang[kandangID]; ok {
continue
}
seenKandang[kandangID] = struct{}{}
pfk, err := pfkRepo.GetActiveByKandangID(ctx, kandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// nggak ada project aktif untuk kandang ini → aman
continue
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
}
// INTI RULE: kalau aktif tapi sudah punya ClosedAt → anggap "project sudah closing"
if pfk != nil && pfk.ClosedAt != nil {
return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
}
}
return nil
}
func EnsureProjectFlockNotClosedByProjectFlockKandangID(
ctx context.Context,
db *gorm.DB,
pfkIDs []uint,
) error {
pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
seen := make(map[uint]struct{})
for _, id := range pfkIDs {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
pfk, err := pfkRepo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Project flock kandang %d tidak ditemukan", id))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
}
if pfk.ClosedAt != nil {
return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
}
}
return nil
}
@@ -0,0 +1,104 @@
package service
import (
"strings"
"time"
)
const (
depreciationStartAgeDayCloseHouse = 155
depreciationStartAgeDayOpenHouse = 176
)
func NormalizeDepreciationHouseType(raw string) string {
return strings.TrimSpace(strings.ToLower(raw))
}
func DepreciationStartAgeDay(houseType string) int {
switch NormalizeDepreciationHouseType(houseType) {
case "close_house":
return depreciationStartAgeDayCloseHouse
case "open_house":
return depreciationStartAgeDayOpenHouse
default:
return 0
}
}
func FlockAgeDay(originDate time.Time, periodDate time.Time) int {
origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, originDate.Location())
period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location())
if period.Before(origin) {
return 0
}
return int(period.Sub(origin).Hours() / 24)
}
func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int {
ageDay := FlockAgeDay(originDate, periodDate)
startAgeDay := DepreciationStartAgeDay(houseType)
if ageDay <= 0 || startAgeDay <= 0 || ageDay < startAgeDay {
return 0
}
return ageDay - startAgeDay + 1
}
func CalculateDepreciationAtDayN(
initialPulletCost float64,
dayN int,
houseType string,
percentByHouseType map[string]map[int]float64,
) (float64, float64, float64) {
return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, percentByHouseType)
}
func CalculateDepreciationFromDayRange(
initialPulletCost float64,
startDay int,
endDay int,
houseType string,
percentByHouseType map[string]map[int]float64,
) (float64, float64, float64) {
if initialPulletCost <= 0 || endDay <= 0 {
return 0, 0, 0
}
if startDay <= 0 {
startDay = 1
}
if endDay < startDay {
return 0, 0, 0
}
normalizedHouseType := NormalizeDepreciationHouseType(houseType)
housePercent, exists := percentByHouseType[normalizedHouseType]
if !exists {
return 0, 0, 0
}
current := initialPulletCost
pulletCostDayN := 0.0
depreciationValue := 0.0
depreciationPercent := 0.0
for day := startDay; day <= endDay; day++ {
pct := housePercent[day]
dep := current * (pct / 100)
if day == endDay {
pulletCostDayN = current
depreciationValue = dep
depreciationPercent = pct
}
current -= dep
if current < 0 {
current = 0
}
}
return pulletCostDayN, depreciationValue, depreciationPercent
}
func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 {
if totalPulletCostDayN <= 0 {
return 0
}
return (totalDepreciationValue / totalPulletCostDayN) * 100
}
@@ -0,0 +1,81 @@
package service
import (
"testing"
"time"
)
func TestDepreciationScheduleDay_UsesHouseTypeOffsets(t *testing.T) {
openOrigin := mustDepreciationDate(t, "2026-01-01")
if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-24"), "open_house"); got != 0 {
t.Fatalf("expected open house day before start to be 0, got %d", got)
}
if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-25"), "open_house"); got != 1 {
t.Fatalf("expected open house start day to map to schedule day 1, got %d", got)
}
closeOrigin := mustDepreciationDate(t, "2026-01-01")
if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-03"), "close_house"); got != 0 {
t.Fatalf("expected close house day before start to be 0, got %d", got)
}
if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-04"), "close_house"); got != 1 {
t.Fatalf("expected close house start day to map to schedule day 1, got %d", got)
}
}
func TestCalculateDepreciationAtDayN_UsesRemainingBasisRecursively(t *testing.T) {
percentByHouseType := map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
},
}
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN(1000, 2, "close_house", percentByHouseType)
if pulletCostDayN != 900 {
t.Fatalf("expected remaining basis entering day 2 to be 900, got %v", pulletCostDayN)
}
if depreciationValue != 180 {
t.Fatalf("expected day 2 depreciation to be 180, got %v", depreciationValue)
}
if depreciationPercent != 20 {
t.Fatalf("expected day 2 depreciation percent to be 20, got %v", depreciationPercent)
}
}
func TestCalculateDepreciationFromDayRange_StartsFromProvidedScheduleDay(t *testing.T) {
percentByHouseType := map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
3: 5,
},
}
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange(1000, 2, 3, "close_house", percentByHouseType)
if pulletCostDayN != 800 {
t.Fatalf("expected remaining basis entering day 3 to be 800, got %v", pulletCostDayN)
}
if depreciationValue != 40 {
t.Fatalf("expected day 3 depreciation to be 40, got %v", depreciationValue)
}
if depreciationPercent != 5 {
t.Fatalf("expected day 3 depreciation percent to be 5, got %v", depreciationPercent)
}
}
func mustDepreciationDate(t *testing.T, raw string) time.Time {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading timezone: %v", err)
}
value, err := time.ParseInLocation("2006-01-02", raw, location)
if err != nil {
t.Fatalf("failed parsing date %q: %v", raw, err)
}
return value
}
@@ -6,8 +6,10 @@ import (
"fmt"
"mime"
"mime/multipart"
"net/url"
"path/filepath"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gitlab.com/mbugroup/lti-api.git/internal/config"
@@ -18,7 +20,7 @@ import (
)
const (
defaultDocumentPathLimit = 50
defaultDocumentPathLimit = 255
defaultDocumentKeyPrefix = "docs"
maxDocumentNameLength = 50
)
@@ -29,6 +31,7 @@ type DocumentService interface {
DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error
DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error
PublicURL(document entity.Document) string
PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error)
}
type DocumentUploadRequest struct {
@@ -293,6 +296,66 @@ func (s *documentService) PublicURL(document entity.Document) string {
return s.storage.URL(document.Path)
}
func (s *documentService) PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error) {
if s.storage == nil {
return "", errors.New("document storage not configured")
}
if strings.TrimSpace(document.Path) == "" {
return "", errors.New("document path is required")
}
return s.storage.PresignURL(ctx, document.Path, expires)
}
// ResolveDocumentURL normalizes a stored path or URL into a presigned URL.
func ResolveDocumentURL(
ctx context.Context,
svc DocumentService,
rawPath string,
expires time.Duration,
) (string, error) {
if svc == nil {
return "", nil
}
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
return "", nil
}
key := rawPath
lower := strings.ToLower(rawPath)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
key = extractS3KeyFromURL(rawPath)
if key == "" {
return "", nil
}
}
return svc.PresignURL(ctx, entity.Document{Path: key}, expires)
}
func extractS3KeyFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
path := strings.TrimPrefix(parsed.Path, "/")
if path == "" {
return ""
}
host := strings.ToLower(strings.TrimSpace(parsed.Host))
if strings.HasPrefix(host, "s3.") || strings.HasPrefix(host, "s3-") {
parts := strings.SplitN(path, "/", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}
return path
}
func (s *documentService) generateObjectKey(ext string) (string, error) {
normalizedExt := strings.TrimSpace(ext)
if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") {
@@ -300,13 +363,19 @@ func (s *documentService) generateObjectKey(ext string) (string, error) {
}
u := uuid.New().String()
key := fmt.Sprintf("%s/%s%s", strings.Trim(s.keyPrefix, "/"), u, normalizedExt)
if s.keyPrefix == "" {
key = fmt.Sprintf("%s%s", u, normalizedExt)
keyPrefix := strings.Trim(s.keyPrefix, "/")
key := fmt.Sprintf("%s%s", u, normalizedExt)
if keyPrefix != "" {
key = fmt.Sprintf("%s/%s%s", keyPrefix, u, normalizedExt)
}
if len(key) > s.maxPathLength {
key = fmt.Sprintf("%s%s", u, normalizedExt)
compact := strings.ReplaceAll(u, "-", "")
if keyPrefix != "" {
key = fmt.Sprintf("%s/%s%s", keyPrefix, compact, normalizedExt)
} else {
key = fmt.Sprintf("%s%s", compact, normalizedExt)
}
}
if len(key) > s.maxPathLength {
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
@@ -17,6 +18,7 @@ type DocumentStorage interface {
Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error)
Delete(ctx context.Context, key string) error
URL(key string) string
PresignURL(ctx context.Context, key string, expires time.Duration) (string, error)
}
type DocumentStorageUploadResult struct {
@@ -36,9 +38,10 @@ type S3DocumentStorageConfig struct {
}
type s3DocumentStorage struct {
client *s3.Client
bucket string
base string
client *s3.Client
presignClient *s3.PresignClient
bucket string
base string
}
func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (DocumentStorage, error) {
@@ -86,6 +89,7 @@ func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (Doc
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.UsePathStyle = cfg.ForcePathStyle
})
presignClient := s3.NewPresignClient(client)
baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/")
if baseURL == "" {
@@ -97,9 +101,10 @@ func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (Doc
}
return &s3DocumentStorage{
client: client,
bucket: bucket,
base: baseURL,
client: client,
presignClient: presignClient,
bucket: bucket,
base: baseURL,
}, nil
}
@@ -158,3 +163,23 @@ func (s *s3DocumentStorage) URL(key string) string {
}
return fmt.Sprintf("%s/%s", s.base, key)
}
func (s *s3DocumentStorage) PresignURL(ctx context.Context, key string, expires time.Duration) (string, error) {
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
if key == "" {
return "", errors.New("storage key is required")
}
if expires <= 0 {
expires = 15 * time.Minute
}
out, err := s.presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(expires))
if err != nil {
return "", err
}
return out.URL, nil
}
+200 -52
View File
@@ -25,6 +25,8 @@ type FifoService interface {
Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error)
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error
ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error)
}
type fifoService struct {
@@ -95,12 +97,26 @@ type StockReplenishRequest struct {
Tx *gorm.DB
}
type StockAdjustRequest struct {
StockableKey fifo.StockableKey
StockableID uint
ProductWarehouseID uint
Quantity float64
Note *string
Tx *gorm.DB
}
type PendingResolution struct {
UsableKey fifo.UsableKey
UsableID uint
Quantity float64
}
type PendingResolveRequest struct {
ProductWarehouseID uint
Tx *gorm.DB
}
type StockReplenishResult struct {
AddedQuantity float64
PendingResolved []PendingResolution
@@ -138,6 +154,38 @@ type StockReleaseRequest struct {
Tx *gorm.DB
}
func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return errors.New("stockable key and id are required")
}
if req.ProductWarehouseID == 0 {
return errors.New("product warehouse id is required")
}
if req.Quantity == 0 {
return nil
}
if req.Quantity > 0 {
return errors.New("quantity must be negative")
}
cfg, ok := fifo.Stockable(req.StockableKey)
if !ok {
return fmt.Errorf("stockable %q is not registered", req.StockableKey)
}
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil {
return err
}
return s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
req.ProductWarehouseID: req.Quantity,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
})
})
}
func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return nil, errors.New("stockable key and id are required")
@@ -185,6 +233,23 @@ func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest)
return result, nil
}
func (s *fifoService) ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error) {
if req.ProductWarehouseID == 0 {
return nil, errors.New("product warehouse id is required")
}
var resolved []PendingResolution
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
var err error
resolved, err = s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
return err
})
if err != nil {
return nil, err
}
return resolved, nil
}
func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) {
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return nil, errors.New("usable key and id are required")
@@ -192,7 +257,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
if req.Quantity < 0 {
return nil, errors.New("quantity must be zero or greater")
}
cfg, ok := fifo.Usable(req.UsableKey)
if !ok {
return nil, fmt.Errorf("usable %q is not registered", req.UsableKey)
@@ -220,7 +284,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
currentPending := ctxRow.PendingQty
currentTotal := currentUsage + currentPending
delta := req.Quantity - currentTotal
var (
usageDelta float64
pendingDelta float64
@@ -230,7 +293,13 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
switch {
case delta > 0:
allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta)
var excludedStockables []fifo.StockableKey
if cfg.ExcludedStockables != nil {
excludedStockables = cfg.ExcludedStockables
}
allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta, excludedStockables)
if err != nil {
return err
}
@@ -263,7 +332,7 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
}
if reductionTarget > 0 {
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget)
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget, productWarehouseID)
if err != nil {
return err
}
@@ -285,7 +354,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
result.ReleasedQuantity = releasedAmount
result.UsageQuantity = currentUsage + usageDelta
result.PendingQuantity = currentPending + pendingDelta
return nil
})
if err != nil {
@@ -299,7 +367,6 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return errors.New("usable key and id are required")
}
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
cfg, ok := fifo.Usable(req.UsableKey)
if !ok {
@@ -310,10 +377,9 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
if err != nil {
return err
}
var usageDelta, pendingDelta float64
if ctxRow.UsageQty > 0 {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty, ctxRow.ProductWarehouseID); err != nil {
return err
}
usageDelta -= ctxRow.UsageQty
@@ -415,8 +481,9 @@ func (s *fifoService) allocateFromStock(
usableKey fifo.UsableKey,
usableID uint,
requestQty float64,
excludedStockables []fifo.StockableKey,
) (*allocationOutcome, error) {
lots, err := s.fetchStockLots(ctx, tx, productWarehouseID)
lots, err := s.fetchStockLots(ctx, tx, productWarehouseID, excludedStockables)
if err != nil {
return nil, err
}
@@ -461,6 +528,7 @@ func (s *fifoService) allocateFromStock(
UsableType: usableKey.String(),
UsableId: usableID,
Qty: portion,
AllocationPurpose: entities.StockAllocationPurposeConsume,
Status: entities.StockAllocationStatusActive,
})
@@ -497,20 +565,43 @@ func (s *fifoService) allocateFromStock(
}, nil
}
func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]stockLot, error) {
func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint, excludedStockables []fifo.StockableKey) ([]stockLot, error) {
configs := fifo.Stockables()
if len(configs) == 0 {
return nil, nil
}
// Create exclusion set for faster lookup
excludedSet := make(map[fifo.StockableKey]bool)
for _, key := range excludedStockables {
excludedSet[key] = true
}
var lots []stockLot
for key, cfg := range configs {
selectStmt := fmt.Sprintf(
"%s AS id, %s AS available_qty, %s AS created_at",
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
cfg.Columns.CreatedAt,
)
// Skip excluded stockables
if excludedSet[key] {
continue
}
usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID
var selectStmt string
if usesNumericTime {
selectStmt = fmt.Sprintf(
"%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at",
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
)
} else {
selectStmt = fmt.Sprintf(
"%s AS id, %s AS available_qty, %s AS created_at",
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
cfg.Columns.CreatedAt,
)
}
var rows []struct {
ID uint
@@ -608,7 +699,13 @@ func (s *fifoService) resolvePendingForWarehouse(ctx context.Context, tx *gorm.D
continue
}
outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending)
// Get excluded stockables from candidate usable config
var excludedStockables []fifo.StockableKey
if candidate.Config.ExcludedStockables != nil {
excludedStockables = candidate.Config.ExcludedStockables
}
outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending, excludedStockables)
if err != nil {
return nil, err
}
@@ -649,6 +746,7 @@ func (s *fifoService) releaseUsagePortion(
usableKey fifo.UsableKey,
usableID uint,
target float64,
expectedWarehouseID uint,
) (float64, error) {
if target <= 0 {
return 0, nil
@@ -664,6 +762,18 @@ func (s *fifoService) releaseUsagePortion(
if len(allocations) == 0 {
return 0, nil
}
for i := range allocations {
alloc := &allocations[i]
if expectedWarehouseID == 0 || alloc.ProductWarehouseId == expectedWarehouseID {
continue
}
if err := tx.Model(&entities.StockAllocation{}).
Where("id = ?", alloc.Id).
Update("product_warehouse_id", expectedWarehouseID).Error; err != nil {
return 0, err
}
alloc.ProductWarehouseId = expectedWarehouseID
}
var (
remaining = target
@@ -702,7 +812,7 @@ func (s *fifoService) releaseUsagePortion(
}
} else {
if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{
"quantity": allocation.Qty - releaseAmt,
"qty": allocation.Qty - releaseAmt,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
@@ -760,41 +870,79 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
cfg.Columns.CreatedAt,
)
var rows []struct {
ID uint
Pending float64
CreatedAt time.Time
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
if cfg.Columns.CreatedAt == cfg.Columns.ID {
var rows []struct {
ID uint
Pending float64 `gorm:"column:pending_qty"`
CreatedAt int64 `gorm:"column:created_at"`
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: time.Unix(0, row.CreatedAt),
})
}
} else {
var rows []struct {
ID uint
Pending float64 `gorm:"column:pending_qty"`
CreatedAt time.Time `gorm:"column:created_at"`
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: row.CreatedAt,
})
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: row.CreatedAt,
})
}
}
@@ -0,0 +1,41 @@
package service
import (
"github.com/sirupsen/logrus"
fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
"gorm.io/gorm"
)
type FifoStockV2Service = fifoStockV2.Service
type FifoStockV2Lane = fifoStockV2.Lane
type FifoStockV2Ref = fifoStockV2.Ref
type FifoStockV2GatherRequest = fifoStockV2.GatherRequest
type FifoStockV2GatherRow = fifoStockV2.GatherRow
type FifoStockV2AllocateRequest = fifoStockV2.AllocateRequest
type FifoStockV2AllocateResult = fifoStockV2.AllocateResult
type FifoStockV2AllocationDetail = fifoStockV2.AllocationDetail
type FifoStockV2RollbackRequest = fifoStockV2.RollbackRequest
type FifoStockV2RollbackResult = fifoStockV2.RollbackResult
type FifoStockV2ReflowRequest = fifoStockV2.ReflowRequest
type FifoStockV2ReflowResult = fifoStockV2.ReflowResult
type FifoStockV2RecalculateRequest = fifoStockV2.RecalculateRequest
type FifoStockV2RecalculateResult = fifoStockV2.RecalculateResult
type FifoStockV2WarehouseDrift = fifoStockV2.WarehouseDrift
func NewFifoStockV2Service(db *gorm.DB, logger *logrus.Logger) FifoStockV2Service {
return fifoStockV2.NewService(db, logger)
}
@@ -0,0 +1,381 @@
package service
import (
"context"
"math"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
)
type HppService interface {
CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error)
GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error)
GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error)
GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error)
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error)
}
type HppCostResponse struct {
Estimation HppCostDetail `json:"estimation"`
Real HppCostDetail `json:"real"`
DebugValues *HppCostDebugValues `json:"debug_values,omitempty"`
}
type HppCostDetail struct {
HargaKg float64 `json:"harga_kg"`
HargaButir float64 `json:"harga_butir"`
Total float64 `json:"total"`
Kg float64 `json:"kg"`
Butir float64 `json:"butir"`
}
type hppService struct {
hppRepo commonRepo.HppCostRepository
}
func NewHppService(hppRepo commonRepo.HppCostRepository) HppService {
return &hppService{hppRepo: hppRepo}
}
func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) {
if date == nil {
now := time.Now()
date = &now
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, err
}
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
endOfDay := startOfDay.Add(24 * time.Hour)
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
if err != nil {
return nil, err
}
result, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
if err != nil {
return nil, err
}
return result, nil
}
func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
if s.hppRepo == nil {
return 0, nil
}
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date)
if err != nil {
return 0, err
}
ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date)
if err != nil {
return 0, err
}
total := docCost + budgetCost + expedisionCost + feedCost + ovkCost
return total, nil
}
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) {
// if date == nil {
// now := time.Now()
// date = &now
// }
costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return 0, err
}
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
// fmt.Println(costBudget, costExpedision, costOvk, costFeed, costPullet, depresiasiTransfer)
// depresiasiTransfer = 0
total := depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget
return total, nil
}
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
// if date == nil {
// now := time.Now()
// date = &now
// }
if s.hppRepo == nil {
return 0, nil
}
projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId)
if err != nil {
return 0, err
}
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
if err != nil {
return 0, err
}
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId)
if err != nil {
return 0, err
}
if eggProduksiPiecesFlock == 0 {
return 0, nil
}
result := (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock
return result, nil
}
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
if endDate == nil {
now := time.Now()
endDate = &now
}
if s.hppRepo == nil {
return 0, nil
}
sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
if sourceProjectFlockID == 0 || transferTotalQty <= 0 {
result, fallbackErr := s.getManualDepresiasiTransferFallback(projectFlockKandangId)
if fallbackErr != nil {
return 0, fallbackErr
}
return result, nil
}
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing)
if err != nil {
return 0, err
}
if totalPopulationFlockGrowing == 0 {
return 0, nil
}
totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, endDate)
if err != nil {
return 0, err
}
result := (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing
return result, nil
}
func (s *hppService) getManualDepresiasiTransferFallback(projectFlockKandangId uint) (float64, error) {
projectFlockID, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
if projectFlockID == 0 {
return 0, nil
}
manualCost, err := s.hppRepo.GetManualDepreciationCostByProjectFlockID(context.Background(), projectFlockID)
if err != nil {
return 0, err
}
if manualCost <= 0 {
return 0, nil
}
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockID)
if err != nil {
return 0, err
}
if len(kandangIDs) == 0 {
return 0, nil
}
totalUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
if totalUsageQty <= 0 {
return 0, nil
}
kandangUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return 0, err
}
if kandangUsageQty <= 0 {
return 0, nil
}
result := manualCost * (kandangUsageQty / totalUsageQty)
return result, nil
}
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
if s.hppRepo == nil {
return &HppCostResponse{}, nil
}
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return nil, err
}
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
if err != nil {
return nil, err
}
estimation := HppCostDetail{
Total: totalProductionCost,
Kg: estimWeightKg,
Butir: estimPieces,
}
if estimWeightKg > 0 {
estimation.HargaKg = roundToTwoDecimals(totalProductionCost / estimWeightKg)
}
if estimPieces > 0 {
estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces)
}
real := HppCostDetail{
Total: totalProductionCost,
Kg: realWeightKg,
Butir: realPieces,
}
if realWeightKg > 0 {
real.HargaKg = roundToTwoDecimals(totalProductionCost / realWeightKg)
}
if realPieces > 0 {
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
}
result := &HppCostResponse{
Estimation: estimation,
Real: real,
}
return result, nil
}
func roundToTwoDecimals(value float64) float64 {
result := math.Round(value*100) / 100
return result
}
func formatTimePtr(value *time.Time) string {
if value == nil {
return "<nil>"
}
return value.Format(time.RFC3339)
}
@@ -0,0 +1,70 @@
package service
type HppV2DateWindow struct {
Start string `json:"start"`
End string `json:"end"`
}
type HppV2Proration struct {
Basis string `json:"basis"`
Numerator float64 `json:"numerator"`
Denominator float64 `json:"denominator"`
Ratio float64 `json:"ratio"`
}
type HppV2Reference struct {
Type string `json:"type"`
ID uint `json:"id"`
StockableType string `json:"stockable_type,omitempty"`
ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"`
ProductID uint `json:"product_id,omitempty"`
ProductName string `json:"product_name,omitempty"`
Date string `json:"date,omitempty"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
Total float64 `json:"total"`
AppliedTotal float64 `json:"applied_total"`
}
type HppV2ComponentPart struct {
Code string `json:"code"`
Title string `json:"title"`
Scopes []string `json:"scopes,omitempty"`
Total float64 `json:"total"`
Proration *HppV2Proration `json:"proration,omitempty"`
Details map[string]any `json:"details,omitempty"`
References []HppV2Reference `json:"references,omitempty"`
}
type HppV2Component struct {
Code string `json:"code"`
Title string `json:"title"`
Scopes []string `json:"scopes,omitempty"`
Total float64 `json:"total"`
Parts []HppV2ComponentPart `json:"parts"`
}
type 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"`
ProjectFlockCategory string `json:"project_flock_category,omitempty"`
HouseType string `json:"house_type,omitempty"`
KandangID uint `json:"kandang_id,omitempty"`
KandangName string `json:"kandang_name,omitempty"`
LocationID uint `json:"location_id,omitempty"`
PeriodDate string `json:"period_date"`
Window HppV2DateWindow `json:"window"`
TotalPulletCost float64 `json:"total_pullet_cost"`
TotalProductionCost float64 `json:"total_production_cost"`
Components []HppV2Component `json:"components"`
Hpp HppCostResponse `json:"hpp"`
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,888 @@
package service
import (
"context"
"fmt"
"sort"
"strings"
"testing"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
type hppV2RepoStub struct {
contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext
pfkIDsByProject map[uint][]uint
latestTransferByPFK map[uint]*commonRepo.HppV2LatestTransferInputRow
manualInputByProject map[uint]*commonRepo.HppV2ManualDepreciationInputRow
snapshotByProjectKey map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow
chickInDateByProject map[uint]*time.Time
depreciationByHouse map[string]map[int]float64
usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow
adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
routeCostByProject map[uint]float64
totalPopulationByKey map[string]float64
transferSummaryByPFK map[uint]struct {
projectFlockID uint
totalQty float64
}
eggProductionByPFK map[uint]struct {
pieces float64
kg float64
}
eggSalesByPFK map[uint]struct {
pieces float64
kg float64
}
}
func (s *hppV2RepoStub) GetProjectFlockKandangContext(_ context.Context, projectFlockKandangId uint) (*commonRepo.HppV2ProjectFlockKandangContext, error) {
row := s.contextByPFK[projectFlockKandangId]
if row == nil {
return nil, fmt.Errorf("pfk %d not found", projectFlockKandangId)
}
return row, nil
}
func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFlockId uint) ([]uint, error) {
return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil
}
func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) (*commonRepo.HppV2LatestTransferInputRow, error) {
return s.latestTransferByPFK[projectFlockKandangId], nil
}
func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) {
return s.manualInputByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(_ context.Context, projectFlockID uint, _ time.Time) (float64, error) {
return s.routeCostByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) {
if s.snapshotByProjectKey == nil {
return nil, nil
}
return s.snapshotByProjectKey[fmt.Sprintf("%d|%s", projectFlockID, periodDate.Format("2006-01-02"))], nil
}
func (s *hppV2RepoStub) GetEarliestChickInDateByProjectFlockID(_ context.Context, projectFlockID uint) (*time.Time, error) {
return s.chickInDateByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) {
result := make(map[string]map[int]float64)
for _, houseType := range houseTypes {
source := s.depreciationByHouse[houseType]
if len(source) == 0 {
continue
}
result[houseType] = make(map[int]float64)
for day, pct := range source {
if day <= maxDay {
result[houseType][day] = pct
}
}
}
return result, nil
}
func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) {
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
}
func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) {
return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
}
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil
}
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil
}
func (s *hppV2RepoStub) ListChickinCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time, excludeTransferToLaying bool) ([]commonRepo.HppV2ChickinCostRow, error) {
return append([]commonRepo.HppV2ChickinCostRow{}, s.chickinRowsByKey[chickinStubKey(projectFlockKandangIDs, flagNames, excludeTransferToLaying)]...), nil
}
func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) {
return 0, nil
}
func (s *hppV2RepoStub) GetTotalPopulation(_ context.Context, projectFlockKandangIDs []uint) (float64, error) {
return s.totalPopulationByKey[stubKey(projectFlockKandangIDs, nil)], nil
}
func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, error) {
totalPieces := 0.0
totalKg := 0.0
for _, projectFlockKandangID := range projectFlockKandangIDs {
row := s.eggProductionByPFK[projectFlockKandangID]
totalPieces += row.pieces
totalKg += row.kg
}
return totalPieces, totalKg, nil
}
func (s *hppV2RepoStub) 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
}
row := s.eggSalesByPFK[projectFlockKandangIDs[0]]
return row.pieces, row.kg, nil
}
func (s *hppV2RepoStub) GetTransferSourceSummary(_ context.Context, projectFlockKandangId uint) (uint, float64, error) {
row := s.transferSummaryByPFK[projectFlockKandangId]
return row.projectFlockID, row.totalQty, nil
}
func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
10: {
ProjectFlockKandangID: 10,
ProjectFlockID: 2,
ProjectFlockCategory: "LAYING",
KandangID: 100,
KandangName: "Kandang A",
LocationID: 16,
},
},
pfkIDsByProject: map[uint][]uint{
1: {101, 102},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{101, 102}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9001, SourceProductID: 8, SourceProductName: "Pakan Growing", Qty: 100, UnitPrice: 40, TotalCost: 4000},
},
stubKey([]uint{10}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9002, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 50, UnitPrice: 30, TotalCost: 1500},
},
},
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
stubKey([]uint{101, 102}, []string{"PAKAN-CUTOVER"}): {
{AdjustmentID: 8001, ProductID: 11, ProductName: "Pakan Growing Cut-over", Qty: 20, Price: 30, GrandTotal: 600},
},
stubKey([]uint{10}, []string{"PAKAN-CUTOVER"}): {
{AdjustmentID: 8002, ProductID: 12, ProductName: "Pakan Laying Cut-over", Qty: 10, Price: 30, GrandTotal: 300},
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{101, 102}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
10: {projectFlockID: 1, totalQty: 250},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
10: {pieces: 100, kg: 10},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
10: {pieces: 40, kg: 4},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(10, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected breakdown result")
}
if got := result.TotalPulletCost; got != 1150 {
t.Fatalf("expected total pullet cost 1150, got %v", got)
}
if got := result.TotalProductionCost; got != 1800 {
t.Fatalf("expected total production cost 1800, got %v", got)
}
if len(result.Components) != 1 {
t.Fatalf("expected 1 component, got %d", len(result.Components))
}
component := result.Components[0]
if component.Code != "PAKAN" {
t.Fatalf("expected PAKAN component, got %s", component.Code)
}
partTotals := map[string]float64{}
for _, part := range component.Parts {
partTotals[part.Code] = part.Total
}
if partTotals[hppV2PartGrowingNormal] != 1000 {
t.Fatalf("expected growing normal 1000, got %v", partTotals[hppV2PartGrowingNormal])
}
if partTotals[hppV2PartGrowingCutover] != 150 {
t.Fatalf("expected growing cutover 150, got %v", partTotals[hppV2PartGrowingCutover])
}
if partTotals[hppV2PartLayingNormal] != 1500 {
t.Fatalf("expected laying normal 1500, got %v", partTotals[hppV2PartLayingNormal])
}
if partTotals[hppV2PartLayingCutover] != 300 {
t.Fatalf("expected laying cutover 300, got %v", partTotals[hppV2PartLayingCutover])
}
if component.Parts[0].Proration == nil || component.Parts[0].Proration.Ratio != 0.25 {
t.Fatalf("expected growing proration ratio 0.25, got %+v", component.Parts[0].Proration)
}
if result.Hpp.Estimation.HargaKg != 180 {
t.Fatalf("expected estimation harga/kg 180, got %v", result.Hpp.Estimation.HargaKg)
}
if result.Hpp.Real.HargaKg != 450 {
t.Fatalf("expected real harga/kg 450, got %v", result.Hpp.Real.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_ManualCutoverUsesLayingSlicesOnly(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
20: {
ProjectFlockKandangID: 20,
ProjectFlockID: 3,
ProjectFlockCategory: "LAYING",
KandangID: 200,
KandangName: "Kandang B",
LocationID: 17,
},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{20}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9100, SourceProductID: 21, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 10, TotalCost: 200},
},
},
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
stubKey([]uint{20}, []string{"PAKAN-CUTOVER"}): {
{AdjustmentID: 8100, ProductID: 22, ProductName: "Pakan Laying Cut-over", Qty: 30, Price: 10, GrandTotal: 300},
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
20: {pieces: 50, kg: 5},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
20: {pieces: 25, kg: 2.5},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(20, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalProductionCost != 500 {
t.Fatalf("expected total production cost 500, got %v", result.TotalProductionCost)
}
component := result.Components[0]
if len(component.Parts) != 2 {
t.Fatalf("expected 2 laying parts, got %d", len(component.Parts))
}
for _, part := range component.Parts {
if strings.HasPrefix(part.Code, "growing_") {
t.Fatalf("expected no growing parts, got %s", part.Code)
}
}
if result.Hpp.Estimation.HargaKg != 100 {
t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
30: {
ProjectFlockKandangID: 30,
ProjectFlockID: 4,
ProjectFlockCategory: "LAYING",
KandangID: 300,
KandangName: "Kandang C",
LocationID: 18,
},
},
pfkIDsByProject: map[uint][]uint{
5: {301, 302},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{30}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9200, SourceProductID: 31, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 25, TotalCost: 500},
},
stubKey([]uint{301, 302}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): {
{StockableType: "purchase_items", StockableID: 9201, SourceProductID: 32, SourceProductName: "OVK Growing", Qty: 40, UnitPrice: 10, TotalCost: 400},
},
stubKey([]uint{30}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): {
{StockableType: "purchase_items", StockableID: 9202, SourceProductID: 33, SourceProductName: "OVK Laying", Qty: 15, UnitPrice: 10, TotalCost: 150},
},
},
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
stubKey([]uint{301, 302}, []string{"OVK"}): {
{AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100},
},
stubKey([]uint{30}, []string{"OVK"}): {
{AdjustmentID: 8202, ProductID: 35, ProductName: "OVK Laying Cut-over", Qty: 5, Price: 10, GrandTotal: 50},
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{301, 302}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
30: {projectFlockID: 5, totalQty: 500},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
30: {pieces: 120, kg: 12},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
30: {pieces: 60, kg: 6},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(30, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected breakdown result")
}
if len(result.Components) != 2 {
t.Fatalf("expected 2 components, got %d", len(result.Components))
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentPakan] != 500 {
t.Fatalf("expected pakan total 500, got %v", componentTotals[hppV2ComponentPakan])
}
if componentTotals[hppV2ComponentOvk] != 450 {
t.Fatalf("expected ovk total 450, got %v", componentTotals[hppV2ComponentOvk])
}
if result.TotalPulletCost != 250 {
t.Fatalf("expected total pullet cost 250, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 700 {
t.Fatalf("expected total production cost 700, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 58.33 {
t.Fatalf("expected estimation harga/kg 58.33, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_IncludesDocAndDirectPulletChickin(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
35: {
ProjectFlockKandangID: 35,
ProjectFlockID: 8,
ProjectFlockCategory: "LAYING",
KandangID: 350,
KandangName: "Kandang E",
LocationID: 20,
},
},
pfkIDsByProject: map[uint][]uint{
9: {901, 902},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{901, 902}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
35: {projectFlockID: 9, totalQty: 250},
},
chickinRowsByKey: map[string][]commonRepo.HppV2ChickinCostRow{
chickinStubKey([]uint{901, 902}, []string{string(utils.FlagDOC)}, false): {
{ProjectChickinID: 1, ProjectFlockKandangID: 901, ChickInDate: mustTime(t, "2026-04-01"), StockableType: "purchase_items", StockableID: 1001, SourceProductID: 77, SourceProductName: "DOC", Qty: 1000, UnitPrice: 2, TotalCost: 2000},
},
chickinStubKey([]uint{35}, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true): {
{ProjectChickinID: 2, ProjectFlockKandangID: 35, ChickInDate: mustTime(t, "2026-04-15"), StockableType: "purchase_items", StockableID: 1002, SourceProductID: 78, SourceProductName: "Pullet", Qty: 50, UnitPrice: 20, TotalCost: 1000},
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
35: {pieces: 100, kg: 10},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
35: {pieces: 80, kg: 8},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(35, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentDocChickin] != 500 {
t.Fatalf("expected doc chickin total 500, got %v", componentTotals[hppV2ComponentDocChickin])
}
if componentTotals[hppV2ComponentDirectPulletPurchase] != 1000 {
t.Fatalf("expected direct pullet purchase total 1000, got %v", componentTotals[hppV2ComponentDirectPulletPurchase])
}
if result.TotalPulletCost != 500 {
t.Fatalf("expected total pullet cost 500, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 1000 {
t.Fatalf("expected total production cost 1000, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 100 {
t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
40: {
ProjectFlockKandangID: 40,
ProjectFlockID: 6,
ProjectFlockCategory: "LAYING",
KandangID: 400,
KandangName: "Kandang D",
LocationID: 19,
},
},
pfkIDsByProject: map[uint][]uint{
6: {40, 41},
7: {701, 702},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{701, 702}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
40: {projectFlockID: 7, totalQty: 200},
},
expenseRowsByPFKKey: map[string][]commonRepo.HppV2ExpenseCostRow{
expenseStubKey([]uint{701, 702}, false): {
{ExpenseRealizationID: 1, NonstockID: 11, NonstockName: "Growing BOP", Qty: 1, Price: 500, TotalCost: 500, RealizationDate: mustTime(t, "2026-04-10")},
},
expenseStubKey([]uint{40}, false): {
{ExpenseRealizationID: 2, NonstockID: 12, NonstockName: "Laying BOP", Qty: 1, Price: 80, TotalCost: 80, RealizationDate: mustTime(t, "2026-04-19")},
},
expenseStubKey([]uint{701, 702}, true): {
{ExpenseRealizationID: 3, NonstockID: 13, NonstockName: "Growing Expedition", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-11")},
},
expenseStubKey([]uint{40}, true): {
{ExpenseRealizationID: 4, NonstockID: 14, NonstockName: "Laying Expedition", Qty: 1, Price: 40, TotalCost: 40, RealizationDate: mustTime(t, "2026-04-19")},
},
},
expenseRowsByFarmKey: map[string][]commonRepo.HppV2ExpenseCostRow{
expenseFarmKey(7, false): {
{ExpenseRealizationID: 5, NonstockID: 15, NonstockName: "Growing Farm BOP", Qty: 1, Price: 300, TotalCost: 300, RealizationDate: mustTime(t, "2026-04-12")},
},
expenseFarmKey(6, false): {
{ExpenseRealizationID: 6, NonstockID: 16, NonstockName: "Laying Farm BOP", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-19")},
},
expenseFarmKey(7, true): {
{ExpenseRealizationID: 7, NonstockID: 17, NonstockName: "Growing Farm Expedition", Qty: 1, Price: 50, TotalCost: 50, RealizationDate: mustTime(t, "2026-04-12")},
},
expenseFarmKey(6, true): {
{ExpenseRealizationID: 8, NonstockID: 18, NonstockName: "Laying Farm Expedition", Qty: 1, Price: 60, TotalCost: 60, RealizationDate: mustTime(t, "2026-04-19")},
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
40: {pieces: 30, kg: 3},
41: {pieces: 70, kg: 7},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
40: {pieces: 50, kg: 5},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(40, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentBopRegular] != 270 {
t.Fatalf("expected regular BOP total 270, got %v", componentTotals[hppV2ComponentBopRegular])
}
if componentTotals[hppV2ComponentBopEksp] != 88 {
t.Fatalf("expected expedition BOP total 88, got %v", componentTotals[hppV2ComponentBopEksp])
}
if result.TotalPulletCost != 190 {
t.Fatalf("expected total pullet cost 190, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 168 {
t.Fatalf("expected total production cost 168, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 56 {
t.Fatalf("expected estimation harga/kg 56, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_AddsDepreciationForNormalTransfer(t *testing.T) {
sourceChickIn := mustTime(t, "2026-01-01")
reportDate := sourceChickIn.AddDate(0, 0, 154)
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
50: {
ProjectFlockKandangID: 50,
ProjectFlockID: 10,
ProjectFlockCategory: "LAYING",
KandangID: 500,
KandangName: "Kandang F",
LocationID: 21,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
11: {501},
},
latestTransferByPFK: map[uint]*commonRepo.HppV2LatestTransferInputRow{
50: {
ProjectFlockKandangID: 50,
SourceProjectFlockID: 11,
TransferDate: mustTime(t, "2026-05-20"),
TransferQty: 100,
TransferID: 701,
},
},
chickInDateByProject: map[uint]*time.Time{
11: &sourceChickIn,
},
depreciationByHouse: map[string]map[int]float64{
"close_house": {
1: 10,
},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{501}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9301, SourceProductID: 41, SourceProductName: "Pakan Growing", Qty: 25, UnitPrice: 40, TotalCost: 1000},
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{501}, nil): 100,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
50: {projectFlockID: 11, totalQty: 100},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
50: {pieces: 20, kg: 10},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(50, &reportDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalPulletCost != 1000 {
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 100 {
t.Fatalf("expected total production cost 100, got %v", result.TotalProductionCost)
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil {
t.Fatal("expected depreciation component")
}
if depreciation.Total != 100 {
t.Fatalf("expected depreciation total 100, got %v", depreciation.Total)
}
if len(depreciation.Parts) != 1 {
t.Fatalf("expected single depreciation part, got %d", len(depreciation.Parts))
}
if depreciation.Parts[0].Details["schedule_day"] != 1 {
t.Fatalf("expected schedule day 1, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["origin_date"] != "2026-01-01" {
t.Fatalf("expected origin date 2026-01-01, got %+v", depreciation.Parts[0].Details)
}
if result.Hpp.Estimation.HargaKg != 10 {
t.Fatalf("expected estimation harga/kg 10, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_AddsDepreciationForManualCutoverFromCutoverDate(t *testing.T) {
originDate := mustTime(t, "2026-01-01")
cutoverDate := originDate.AddDate(0, 0, 155)
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
60: {
ProjectFlockKandangID: 60,
ProjectFlockID: 12,
ProjectFlockCategory: "LAYING",
KandangID: 600,
KandangName: "Kandang G",
LocationID: 22,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
12: {60},
},
manualInputByProject: map[uint]*commonRepo.HppV2ManualDepreciationInputRow{
12: {
ID: 801,
ProjectFlockID: 12,
TotalCost: 1000,
CutoverDate: cutoverDate,
},
},
chickInDateByProject: map[uint]*time.Time{
12: &originDate,
},
depreciationByHouse: map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{60}, nil): 100,
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
60: {pieces: 20, kg: 10},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(60, &cutoverDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalPulletCost != 1000 {
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 200 {
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentManualPulletCost] != 1000 {
t.Fatalf("expected manual pullet cost 1000, got %v", componentTotals[hppV2ComponentManualPulletCost])
}
if componentTotals[hppV2ComponentDepreciation] != 200 {
t.Fatalf("expected depreciation 200, got %v", componentTotals[hppV2ComponentDepreciation])
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil || len(depreciation.Parts) != 1 {
t.Fatalf("expected one depreciation part, got %+v", depreciation)
}
if depreciation.Parts[0].Details["schedule_day"] != 2 {
t.Fatalf("expected schedule day 2, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["start_schedule_day"] != 2 {
t.Fatalf("expected start schedule day 2, got %+v", depreciation.Parts[0].Details)
}
if result.Hpp.Estimation.HargaKg != 20 {
t.Fatalf("expected estimation harga/kg 20, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggProduction(t *testing.T) {
reportDate := mustTime(t, "2026-06-05")
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
70: {
ProjectFlockKandangID: 70,
ProjectFlockID: 15,
ProjectFlockCategory: "LAYING",
KandangID: 700,
KandangName: "Kandang Snapshot",
LocationID: 25,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
15: {70, 71},
},
snapshotByProjectKey: map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow{
"15|2026-06-05": {
ID: 901,
ProjectFlockID: 15,
PeriodDate: reportDate,
DepreciationPercentEffective: 10,
DepreciationValue: 1000,
PulletCostDayNTotal: 10000,
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
70: {pieces: 200, kg: 20},
71: {pieces: 800, kg: 80},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(70, &reportDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected breakdown result")
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil {
t.Fatal("expected depreciation component")
}
if depreciation.Total != 200 {
t.Fatalf("expected depreciation total 200, got %v", depreciation.Total)
}
if result.TotalProductionCost != 200 {
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
}
if len(depreciation.Parts) != 1 {
t.Fatalf("expected one depreciation part, got %d", len(depreciation.Parts))
}
if depreciation.Parts[0].Code != hppV2PartDepreciationFarmSnapshot {
t.Fatalf("expected farm snapshot depreciation part, got %s", depreciation.Parts[0].Code)
}
if depreciation.Parts[0].Proration == nil || depreciation.Parts[0].Proration.Ratio != 0.2 {
t.Fatalf("expected proration ratio 0.2, got %+v", depreciation.Parts[0].Proration)
}
if depreciation.Parts[0].Details["snapshot_id"] != uint(901) {
t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details)
}
}
func stubKey(ids []uint, flags []string) string {
idParts := make([]string, 0, len(ids))
for _, id := range ids {
idParts = append(idParts, fmt.Sprintf("%d", id))
}
sort.Strings(idParts)
flagParts := append([]string{}, flags...)
sort.Strings(flagParts)
return strings.Join(idParts, ",") + "|" + strings.Join(flagParts, ",")
}
func mustDate(t *testing.T, raw string) *time.Time {
t.Helper()
loc, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed to load timezone: %v", err)
}
value, err := time.ParseInLocation("2006-01-02", raw, loc)
if err != nil {
t.Fatalf("failed to parse date %s: %v", raw, err)
}
return &value
}
func mustTime(t *testing.T, raw string) time.Time {
t.Helper()
value := mustDate(t, raw)
return *value
}
func expenseStubKey(ids []uint, ekspedisi bool) string {
return stubKey(ids, []string{fmt.Sprintf("ekspedisi=%t", ekspedisi)})
}
func expenseFarmKey(projectFlockID uint, ekspedisi bool) string {
return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi)
}
func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string {
return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying)))
}
@@ -0,0 +1,103 @@
package service
import (
"context"
"time"
"gorm.io/gorm"
)
const farmDepreciationSnapshotTable = "farm_depreciation_snapshots"
func NormalizeDateOnlyUTC(value time.Time) time.Time {
if value.IsZero() {
return value
}
v := value.UTC()
return time.Date(v.Year(), v.Month(), v.Day(), 0, 0, 0, 0, time.UTC)
}
func MinNonZeroDateOnlyUTC(values ...time.Time) time.Time {
var out time.Time
for _, value := range values {
if value.IsZero() {
continue
}
normalized := NormalizeDateOnlyUTC(value)
if out.IsZero() || normalized.Before(out) {
out = normalized
}
}
return out
}
func InvalidateFarmDepreciationSnapshotsFromDate(ctx context.Context, db *gorm.DB, farmIDs []uint, fromDate time.Time) error {
if db == nil {
return nil
}
if fromDate.IsZero() {
return nil
}
fromDate = NormalizeDateOnlyUTC(fromDate)
query := db.WithContext(ctx).
Table(farmDepreciationSnapshotTable).
Where("period_date >= ?", fromDate)
if len(farmIDs) > 0 {
query = query.Where("project_flock_id IN ?", farmIDs)
}
return query.Delete(nil).Error
}
func ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx context.Context, db *gorm.DB, pfkIDs []uint) ([]uint, error) {
if db == nil || len(pfkIDs) == 0 {
return []uint{}, nil
}
var projectFlockIDs []uint
if err := db.WithContext(ctx).
Table("project_flock_kandangs").
Distinct("project_flock_id").
Where("id IN ?", pfkIDs).
Pluck("project_flock_id", &projectFlockIDs).Error; err != nil {
return nil, err
}
return projectFlockIDs, nil
}
func ResolveProjectFlockIDsByExpenseID(ctx context.Context, db *gorm.DB, expenseID uint) ([]uint, error) {
if db == nil || expenseID == 0 {
return []uint{}, nil
}
query := `
WITH direct_farms AS (
SELECT DISTINCT pfk.project_flock_id
FROM expense_nonstocks ens
JOIN project_flock_kandangs pfk ON pfk.id = ens.project_flock_kandang_id
WHERE ens.expense_id = @expense_id
),
json_farms AS (
SELECT DISTINCT (jsonb_array_elements_text(e.project_flock_id::jsonb))::bigint AS project_flock_id
FROM expenses e
WHERE e.id = @expense_id
AND e.project_flock_id IS NOT NULL
)
SELECT DISTINCT project_flock_id
FROM (
SELECT project_flock_id FROM direct_farms
UNION ALL
SELECT project_flock_id FROM json_farms
) x
`
var ids []uint
if err := db.WithContext(ctx).Raw(query, map[string]any{
"expense_id": expenseID,
}).Scan(&ids).Error; err != nil {
return nil, err
}
return ids, nil
}
@@ -0,0 +1,104 @@
package service
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
)
type FifoPendingPolicyInput struct {
Lane string
FlagGroupCode string
FunctionCode string
LegacyTypeKey string
}
type FifoPendingPolicyResult struct {
AllowPending bool
RuleSource string
Found bool
}
func ResolveFifoPendingPolicy(ctx context.Context, tx *gorm.DB, input FifoPendingPolicyInput) (*FifoPendingPolicyResult, error) {
if tx == nil {
return nil, gorm.ErrInvalidDB
}
lane := strings.ToUpper(strings.TrimSpace(input.Lane))
flagGroupCode := strings.ToUpper(strings.TrimSpace(input.FlagGroupCode))
functionCode := strings.ToUpper(strings.TrimSpace(input.FunctionCode))
legacyTypeKey := strings.ToUpper(strings.TrimSpace(input.LegacyTypeKey))
if lane == "" {
return &FifoPendingPolicyResult{
AllowPending: false,
RuleSource: "SAFE_DEFAULT_BLOCK",
Found: false,
}, nil
}
type overconsumeRuleRow struct {
Allow bool `gorm:"column:allow_overconsume"`
}
var overconsume overconsumeRuleRow
overconsumeErr := tx.WithContext(ctx).
Table("fifo_stock_v2_overconsume_rules").
Select("allow_overconsume").
Where("is_active = TRUE").
Where("lane = ?", lane).
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
Where("(function_code IS NULL OR function_code = ?)", functionCode).
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
Order("priority ASC, id ASC").
Limit(1).
Take(&overconsume).Error
if overconsumeErr == nil {
return &FifoPendingPolicyResult{
AllowPending: overconsume.Allow,
RuleSource: "OVERCONSUME_RULE",
Found: true,
}, nil
}
if !errors.Is(overconsumeErr, gorm.ErrRecordNotFound) {
return nil, overconsumeErr
}
type routeRuleRow struct {
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
}
var routeRule routeRuleRow
routeQuery := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Select("allow_pending_default").
Where("is_active = TRUE").
Where("lane = ?", lane).
Where("flag_group_code = ?", flagGroupCode)
if legacyTypeKey != "" {
routeQuery = routeQuery.Where("legacy_type_key = ?", legacyTypeKey)
}
if functionCode != "" {
routeQuery = routeQuery.Where("function_code = ?", functionCode)
}
routeErr := routeQuery.
Order("id ASC").
Limit(1).
Take(&routeRule).Error
if routeErr == nil {
return &FifoPendingPolicyResult{
AllowPending: routeRule.AllowPendingDefault,
RuleSource: "ROUTE_RULE_DEFAULT",
Found: true,
}, nil
}
if !errors.Is(routeErr, gorm.ErrRecordNotFound) {
return nil, routeErr
}
return &FifoPendingPolicyResult{
AllowPending: false,
RuleSource: "SAFE_DEFAULT_BLOCK",
Found: false,
}, nil
}
@@ -0,0 +1,58 @@
# RFC Ringkas: FIFO Stock V2
## Tujuan
`fifo_stock_v2` adalah engine FIFO baru berbasis konfigurasi `Flag Group + Jalur` yang berjalan paralel dengan v1 tanpa memutus kompatibilitas `stock_allocations`, HPP, dan closing/reporting existing.
## Prinsip
- V1 tidak dihapus, V2 jalan paralel.
- Semua operasi transactional.
- FIFO sorting deterministic lintas tabel.
- Default over-consume `ALLOW` (pending), exception dapat `BLOCK`.
- Reflow idempotent.
- Recalculate bisa memperbaiki drift `product_warehouses.qty`.
## Komponen
- `fifo_stock_v2_flag_groups`: master grouping flag produk.
- `fifo_stock_v2_flag_members`: pemetaan flag -> group.
- `fifo_stock_v2_traits`: trait sort per `table:date_column` (+ optional join date source).
- `fifo_stock_v2_route_rules`: rule per `flag_group + lane + function + table`.
- `fifo_stock_v2_overconsume_rules`: policy pending/over-consume.
- `fifo_stock_v2_operation_log`: idempotency + audit operasi.
- `fifo_stock_v2_reflow_runs` + checkpoints + shadow allocations: bulk reflow resumable/observable.
## API Service
- `Gather`: union cross-table berdasarkan route rules + trait sorting.
- `Allocate`: alokasi lot FIFO ke usable.
- `Rollback`: batalkan alokasi aktif.
- `Reflow`: rollback penuh lalu allocate ulang (idempotent).
- `Recalculate`: rekonsiliasi qty warehouse dari ledger FIFO.
## Deterministic Sorting
Urutan gather:
1. `sort_at ASC` (dari trait `date_column`)
2. `sort_priority ASC`
3. `source_table ASC`
4. `source_id ASC`
Fallback waktu: `1970-01-01 00:00:00+00` bila tanggal null.
## Compat Strategy
- Tetap menulis ke `stock_allocations` dengan tambahan metadata:
- `engine_version` (`v1`/`v2`)
- `flag_group_code`
- `function_code`
- `idempotency_key`
- Query lama yang bergantung `stockable_type/usable_type` tetap berjalan.
## Migration Strategy
1. Deploy schema + seed v2.
2. Aktifkan shadow-run comparator v1 vs v2.
3. Canary cutover per flag group.
4. Full cutover jika parity aman.
5. Jalankan bulk reflow existing data.
## Acceptance Criteria Singkat
- Parity mismatch terkendali pada aggregate + detail alokasi.
- Tidak ada regression closing/HPP.
- Drift qty warehouse turun signifikan pasca reflow.
- Rollback via feature flag memungkinkan kembali ke v1.
@@ -0,0 +1,802 @@
package fifo_stock_v2
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"gorm.io/gorm"
)
type allocationRow struct {
ID uint `gorm:"column:id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
StockableType string `gorm:"column:stockable_type"`
StockableID uint `gorm:"column:stockable_id"`
UsableType string `gorm:"column:usable_type"`
UsableID uint `gorm:"column:usable_id"`
Qty float64 `gorm:"column:qty"`
Status string `gorm:"column:status"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type usableQtySnapshot struct {
Usage float64 `gorm:"column:usage_qty"`
Pending float64 `gorm:"column:pending_qty"`
}
func (s *fifoStockV2Service) Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error) {
if err := s.validateAllocateRequest(req); err != nil {
return nil, err
}
result := &AllocateResult{}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.ensureStockAllocationColumns(tx); err != nil {
return err
}
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
return err
}
hash := requestHash(map[string]any{
"flag_group_code": req.FlagGroupCode,
"product_warehouse_id": req.ProductWarehouseID,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"need_qty": req.NeedQty,
"as_of": req.AsOf,
"allow_over_consume": req.AllowOverConsume,
})
logRow, reused, err := s.beginOperation(
tx,
OperationAllocate,
req.IdempotencyKey,
hash,
req.ProductWarehouseID,
req.FlagGroupCode,
req.Usable.LegacyTypeKey,
req.Usable.ID,
)
if err != nil {
return err
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent allocate has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
allocated, allocErr := s.allocateInternal(ctx, tx, req)
if allocErr != nil {
err = allocErr
return allocErr
}
*result = *allocated
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
err = finishErr
return finishErr
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, req AllocateRequest) (*AllocateResult, error) {
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, req.FlagGroupCode, req.Usable.LegacyTypeKey)
if err != nil {
return nil, err
}
allowOverConsume := usableRule.AllowPendingDefault
if req.AllowOverConsume != nil {
allowOverConsume = *req.AllowOverConsume
} else {
allowOverConsume, err = s.resolveOverConsume(tx, req.FlagGroupCode, req.Usable.FunctionCode, LaneUsable, allowOverConsume)
if err != nil {
return nil, err
}
}
gatherRows, err := s.gatherRows(ctx, tx, GatherRequest{
FlagGroupCode: req.FlagGroupCode,
Lane: LaneStockable,
ProductWarehouseID: req.ProductWarehouseID,
AsOf: req.AsOf,
Limit: s.defaultGatherLimit,
})
if err != nil {
return nil, err
}
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, req.FlagGroupCode)
if err != nil {
return nil, err
}
now := time.Now()
remaining := req.NeedQty
result := &AllocateResult{Details: make([]AllocationDetail, 0)}
for _, lot := range gatherRows {
if remaining <= 0 {
break
}
if shouldSkipStockableForUsable(req, lot.Ref.LegacyTypeKey) {
continue
}
if lot.AvailableQuantity <= 0 {
continue
}
portion := math.Min(remaining, lot.AvailableQuantity)
if nearlyZero(portion) {
continue
}
allocationInsert := map[string]any{
"product_warehouse_id": req.ProductWarehouseID,
"stockable_type": lot.Ref.LegacyTypeKey,
"stockable_id": lot.Ref.ID,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"qty": portion,
"status": activeAllocationStatus(),
"allocation_purpose": defaultAllocationPurpose(),
"created_at": now,
"updated_at": now,
"engine_version": "v2",
"flag_group_code": req.FlagGroupCode,
"function_code": req.Usable.FunctionCode,
}
if strings.TrimSpace(req.IdempotencyKey) != "" {
allocationInsert["idempotency_key"] = req.IdempotencyKey
}
if err := tx.Table("stock_allocations").Create(allocationInsert).Error; err != nil {
return nil, err
}
rule, ok := stockableRuleMap[lot.Ref.LegacyTypeKey]
if !ok {
return nil, fmt.Errorf("missing stockable route rule for type %s", lot.Ref.LegacyTypeKey)
}
if err := s.adjustStockableUsedQuantity(tx, rule, lot.Ref.ID, portion); err != nil {
return nil, err
}
result.Details = append(result.Details, AllocationDetail{
StockableType: lot.Ref.LegacyTypeKey,
StockableID: lot.Ref.ID,
Qty: portion,
SortAt: lot.SortAt,
})
remaining -= portion
result.AllocatedQty += portion
}
if remaining > 0 {
if !allowOverConsume {
return nil, fmt.Errorf("%w: requested %.3f, allocated %.3f", ErrInsufficientStock, req.NeedQty, result.AllocatedQty)
}
result.PendingQty = remaining
}
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, result.AllocatedQty, result.PendingQty); err != nil {
return nil, err
}
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, -result.AllocatedQty); err != nil {
return nil, err
}
return result, nil
}
func shouldSkipStockableForUsable(req AllocateRequest, stockableType string) bool {
usableType := strings.ToUpper(strings.TrimSpace(req.Usable.LegacyTypeKey))
functionCode := strings.ToUpper(strings.TrimSpace(req.Usable.FunctionCode))
stockable := strings.ToUpper(strings.TrimSpace(stockableType))
// CHICKIN_OUT must consume physical stock sources, not population lots,
// otherwise approved chickin can consume its own just-created population.
if (usableType == "PROJECT_CHICKIN" || functionCode == "CHICKIN_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
return true
}
if (usableType == "STOCK_TRANSFER_OUT" || functionCode == "STOCK_TRANSFER_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
return true
}
return false
}
func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) {
if err := s.validateRollbackRequest(req); err != nil {
return nil, err
}
result := &RollbackResult{}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.ensureStockAllocationColumns(tx); err != nil {
return err
}
flagGroupCode, err := s.resolveRollbackFlagGroup(ctx, tx, req)
if err != nil {
return err
}
if err := s.lockShard(tx, flagGroupCode, req.ProductWarehouseID); err != nil {
return err
}
hash := requestHash(map[string]any{
"product_warehouse_id": req.ProductWarehouseID,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"release_qty": req.ReleaseQty,
"reason": req.Reason,
"flag_group_code": flagGroupCode,
})
logRow, reused, beginErr := s.beginOperation(
tx,
OperationRollback,
req.IdempotencyKey,
hash,
req.ProductWarehouseID,
flagGroupCode,
req.Usable.LegacyTypeKey,
req.Usable.ID,
)
if beginErr != nil {
return beginErr
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent rollback has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
rolled, rollbackErr := s.rollbackInternal(ctx, tx, req, flagGroupCode)
if rollbackErr != nil {
err = rollbackErr
return rollbackErr
}
*result = *rolled
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
err = finishErr
return finishErr
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) rollbackInternal(
ctx context.Context,
tx *gorm.DB,
req RollbackRequest,
flagGroupCode string,
) (*RollbackResult, error) {
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, flagGroupCode, req.Usable.LegacyTypeKey)
if err != nil {
return nil, err
}
allocations, err := s.loadActiveAllocations(tx, req.Usable.LegacyTypeKey, req.Usable.ID, req.ProductWarehouseID)
if err != nil {
return nil, err
}
if len(allocations) == 0 {
if req.ReleaseQty == nil {
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
return nil, err
}
}
return &RollbackResult{}, nil
}
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, flagGroupCode)
if err != nil {
return nil, err
}
target := 0.0
for _, alloc := range allocations {
target += alloc.Qty
}
if req.ReleaseQty != nil {
if *req.ReleaseQty < 0 {
return nil, fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
}
target = *req.ReleaseQty
}
if nearlyZero(target) {
return &RollbackResult{}, nil
}
result := &RollbackResult{Details: make([]AllocationDetail, 0)}
now := time.Now()
remaining := target
for _, alloc := range allocations {
if remaining <= 0 {
break
}
portion := math.Min(remaining, alloc.Qty)
if nearlyZero(portion) {
continue
}
if nearlyZero(alloc.Qty - portion) {
updates := map[string]any{
"status": releasedAllocationStatus(),
"released_at": now,
"updated_at": now,
}
if strings.TrimSpace(req.Reason) != "" {
updates["note"] = req.Reason
}
if err := tx.Table("stock_allocations").Where("id = ?", alloc.ID).Updates(updates).Error; err != nil {
return nil, err
}
} else {
if err := tx.Table("stock_allocations").
Where("id = ?", alloc.ID).
Updates(map[string]any{
"qty": alloc.Qty - portion,
"updated_at": now,
}).Error; err != nil {
return nil, err
}
}
stockableRule, ok := stockableRuleMap[alloc.StockableType]
if !ok {
return nil, fmt.Errorf("missing stockable route rule for type %s", alloc.StockableType)
}
if err := s.adjustStockableUsedQuantity(tx, stockableRule, alloc.StockableID, -portion); err != nil {
return nil, err
}
result.ReleasedQty += portion
remaining -= portion
result.Details = append(result.Details, AllocationDetail{
StockableType: alloc.StockableType,
StockableID: alloc.StockableID,
Qty: portion,
SortAt: alloc.CreatedAt,
})
}
if req.ReleaseQty != nil && remaining > 1e-6 {
return nil, fmt.Errorf("unable to release %.3f; only %.3f allocation exists", target, result.ReleasedQty)
}
if req.ReleaseQty == nil {
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
return nil, err
}
} else {
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, -result.ReleasedQty, 0); err != nil {
return nil, err
}
}
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, result.ReleasedQty); err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error) {
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
return nil, fmt.Errorf("%w: invalid reflow request", ErrInvalidRequest)
}
result := &ReflowResult{}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.ensureStockAllocationColumns(tx); err != nil {
return err
}
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
return err
}
hash := requestHash(map[string]any{
"flag_group_code": req.FlagGroupCode,
"product_warehouse_id": req.ProductWarehouseID,
"as_of": req.AsOf,
})
logRow, reused, err := s.beginOperation(
tx,
OperationReflow,
req.IdempotencyKey,
hash,
req.ProductWarehouseID,
req.FlagGroupCode,
"",
0,
)
if err != nil {
return err
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent reflow has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
usableRows, gatherErr := s.gatherAllRows(ctx, tx, GatherRequest{
FlagGroupCode: req.FlagGroupCode,
Lane: LaneUsable,
ProductWarehouseID: req.ProductWarehouseID,
Limit: s.defaultGatherLimit,
})
if gatherErr != nil {
err = gatherErr
return gatherErr
}
result.ProcessedUsables = len(usableRows)
for _, usableRow := range usableRows {
desiredQty := usableRow.Quantity + usableRow.PendingQuantity
rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{
ProductWarehouseID: req.ProductWarehouseID,
Usable: usableRow.Ref,
ReleaseQty: nil,
Reason: "reflow reset",
}, req.FlagGroupCode)
if rollbackErr != nil {
err = rollbackErr
return rollbackErr
}
result.Rollback.ReleasedQty += rollbackRes.ReleasedQty
if len(rollbackRes.Details) > 0 {
result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...)
}
if desiredQty <= 0 {
continue
}
allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{
FlagGroupCode: req.FlagGroupCode,
ProductWarehouseID: req.ProductWarehouseID,
Usable: usableRow.Ref,
NeedQty: desiredQty,
AsOf: nil,
})
if allocateErr != nil {
err = allocateErr
return allocateErr
}
result.Allocate.AllocatedQty += allocateRes.AllocatedQty
result.Allocate.PendingQty += allocateRes.PendingQty
if len(allocateRes.Details) > 0 {
result.Allocate.Details = append(result.Allocate.Details, allocateRes.Details...)
}
}
expectedQty, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, req.ProductWarehouseID, req.FlagGroupCode, nil)
if calcErr != nil {
err = calcErr
return calcErr
}
actualQty, loadErr := s.loadWarehouseQty(ctx, tx, req.ProductWarehouseID)
if loadErr != nil {
err = loadErr
return loadErr
}
drift := expectedQty - actualQty
if math.Abs(drift) >= 1e-6 {
if adjustErr := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, drift); adjustErr != nil {
err = adjustErr
return adjustErr
}
}
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
err = finishErr
return finishErr
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) gatherAllRows(
ctx context.Context,
tx *gorm.DB,
req GatherRequest,
) ([]GatherRow, error) {
limit := req.Limit
if limit <= 0 {
limit = s.defaultGatherLimit
}
if limit <= 0 {
limit = 1000
}
req.Limit = limit
out := make([]GatherRow, 0, limit)
var cursorSortAt *time.Time
cursorSourceTable := ""
var cursorSourceID uint
for {
req.AfterSortAt = cursorSortAt
req.AfterSourceTable = cursorSourceTable
req.AfterSourceID = cursorSourceID
rows, err := s.gatherRows(ctx, tx, req)
if err != nil {
return nil, err
}
if len(rows) == 0 {
break
}
out = append(out, rows...)
if len(rows) < limit {
break
}
last := rows[len(rows)-1]
lastSortAt := last.SortAt
cursorSortAt = &lastSortAt
cursorSourceTable = last.SourceTable
cursorSourceID = last.SourceID
}
return out, nil
}
func (s *fifoStockV2Service) loadActiveAllocations(
tx *gorm.DB,
usableType string,
usableID uint,
productWarehouseID uint,
) ([]allocationRow, error) {
query := tx.Table("stock_allocations").
Select("id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, created_at").
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, activeAllocationStatus(), defaultAllocationPurpose())
if productWarehouseID > 0 {
query = query.Where("product_warehouse_id = ?", productWarehouseID)
}
query = query.Order("created_at DESC, id DESC")
var rows []allocationRow
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (s *fifoStockV2Service) loadStockableRuleMap(ctx context.Context, tx *gorm.DB, flagGroupCode string) (map[string]routeRule, error) {
rules, err := s.loadRouteRules(ctx, tx, flagGroupCode, LaneStockable)
if err != nil {
return nil, err
}
m := make(map[string]routeRule, len(rules))
for _, rule := range rules {
m[rule.LegacyTypeKey] = rule
}
return m, nil
}
func (s *fifoStockV2Service) adjustStockableUsedQuantity(tx *gorm.DB, rule routeRule, sourceID uint, delta float64) error {
if nearlyZero(delta) || sourceID == 0 {
return nil
}
if rule.UsedQuantityCol == nil || strings.TrimSpace(*rule.UsedQuantityCol) == "" {
return nil
}
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usedCol)
return tx.Table(sourceTable).
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
Update(usedCol, gorm.Expr(expr, delta)).Error
}
func (s *fifoStockV2Service) applyUsableDeltas(tx *gorm.DB, rule routeRule, sourceID uint, usageDelta, pendingDelta float64) error {
if sourceID == 0 || (nearlyZero(usageDelta) && nearlyZero(pendingDelta)) {
return nil
}
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
updates := map[string]any{}
if !nearlyZero(usageDelta) {
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usageCol)
updates[usageCol] = gorm.Expr(expr, usageDelta)
}
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" && !nearlyZero(pendingDelta) {
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", pendingCol)
updates[pendingCol] = gorm.Expr(expr, pendingDelta)
}
if len(updates) == 0 {
return nil
}
return tx.Table(sourceTable).
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
Updates(updates).Error
}
func (s *fifoStockV2Service) resetUsableQuantities(tx *gorm.DB, rule routeRule, sourceID uint) error {
if sourceID == 0 {
return nil
}
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
updates := map[string]any{usageCol: 0}
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
updates[pendingCol] = 0
}
return tx.Table(sourceTable).
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
Updates(updates).Error
}
func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *gorm.DB, req RollbackRequest) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var latest row
latestQuery := tx.WithContext(ctx).
Table("stock_allocations").
Select("flag_group_code").
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
Where("engine_version = 'v2'").
Where("allocation_purpose = ?", defaultAllocationPurpose()).
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''")
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
latestQuery = latestQuery.Where("function_code = ?", code)
}
err := latestQuery.Order("id DESC").Limit(1).Take(&latest).Error
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
return latest.FlagGroupCode, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
rulesQuery := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("lane = ?", string(LaneUsable)).
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey)
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
rulesQuery = rulesQuery.Where("function_code = ?", code)
}
var rules []routeRule
err = rulesQuery.Find(&rules).Error
if err != nil {
return "", err
}
if len(rules) == 0 {
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey)
}
if len(rules) > 1 && req.ProductWarehouseID != 0 {
type candidateRow struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var candidates []candidateRow
byProductQuery := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("DISTINCT rr.flag_group_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.lane = ?", string(LaneUsable)).
Where("rr.legacy_type_key = ?", req.Usable.LegacyTypeKey).
Where(`
EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = ?
AND f.flagable_type = 'products'
AND fm.flag_group_code = rr.flag_group_code
)
`, req.ProductWarehouseID)
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
byProductQuery = byProductQuery.Where("rr.function_code = ?", code)
}
if err := byProductQuery.Order("rr.flag_group_code ASC").Scan(&candidates).Error; err != nil {
return "", err
}
if len(candidates) == 1 {
return strings.TrimSpace(candidates[0].FlagGroupCode), nil
}
}
if len(rules) > 1 {
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
}
return rules[0].FlagGroupCode, nil
}
func (s *fifoStockV2Service) validateAllocateRequest(req AllocateRequest) error {
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
return fmt.Errorf("%w: missing flag group or product warehouse", ErrInvalidRequest)
}
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
}
if req.NeedQty < 0 {
return fmt.Errorf("%w: need qty must be >= 0", ErrInvalidRequest)
}
return nil
}
func (s *fifoStockV2Service) validateRollbackRequest(req RollbackRequest) error {
if req.ProductWarehouseID == 0 {
return fmt.Errorf("%w: product warehouse is required", ErrInvalidRequest)
}
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
}
if req.ReleaseQty != nil && *req.ReleaseQty < 0 {
return fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
}
return nil
}
@@ -0,0 +1,170 @@
package fifo_stock_v2
import (
"context"
"fmt"
"strings"
"gorm.io/gorm"
)
type routeRule struct {
ID uint `gorm:"column:id"`
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
SourceTable string `gorm:"column:source_table"`
SourceIDColumn string `gorm:"column:source_id_column"`
ProductWarehouseCol string `gorm:"column:product_warehouse_col"`
QuantityCol string `gorm:"column:quantity_col"`
UsedQuantityCol *string `gorm:"column:used_quantity_col"`
PendingQuantityCol *string `gorm:"column:pending_quantity_col"`
ScopeSQL *string `gorm:"column:scope_sql"`
LegacyTypeKey string `gorm:"column:legacy_type_key"`
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
}
type traitRule struct {
ID uint `gorm:"column:id"`
SourceTable string `gorm:"column:source_table"`
Lane string `gorm:"column:lane"`
DateTable *string `gorm:"column:date_table"`
DateJoinLeftCol *string `gorm:"column:date_join_left_col"`
DateJoinRightCol *string `gorm:"column:date_join_right_col"`
DateColumn string `gorm:"column:date_column"`
FallbackDateColumn *string `gorm:"column:fallback_date_column"`
SortPriority int `gorm:"column:sort_priority"`
IDColumn string `gorm:"column:id_column"`
}
func (s *fifoStockV2Service) loadRouteRules(ctx context.Context, tx *gorm.DB, flagGroupCode string, lane Lane) ([]routeRule, error) {
var rules []routeRule
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("flag_group_code = ?", flagGroupCode).
Where("lane = ?", string(lane)).
Order("id ASC").
Find(&rules).Error
if err != nil {
return nil, err
}
for _, rule := range rules {
if err := validateRouteRule(rule); err != nil {
return nil, err
}
}
return rules, nil
}
func (s *fifoStockV2Service) loadRouteRuleByLegacyType(
ctx context.Context,
tx *gorm.DB,
lane Lane,
flagGroupCode string,
legacyTypeKey string,
) (*routeRule, error) {
var rule routeRule
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("lane = ?", string(lane)).
Where("flag_group_code = ?", flagGroupCode).
Where("legacy_type_key = ?", legacyTypeKey).
Order("id ASC").
Limit(1).
Take(&rule).Error
if err != nil {
return nil, err
}
if err := validateRouteRule(rule); err != nil {
return nil, err
}
return &rule, nil
}
func (s *fifoStockV2Service) loadTraitMap(
ctx context.Context,
tx *gorm.DB,
lane Lane,
sourceTables []string,
) (map[string]traitRule, error) {
if len(sourceTables) == 0 {
return map[string]traitRule{}, nil
}
var traits []traitRule
err := tx.WithContext(ctx).
Table("fifo_stock_v2_traits").
Where("is_active = TRUE").
Where("lane = ?", string(lane)).
Where("source_table IN ?", sourceTables).
Find(&traits).Error
if err != nil {
return nil, err
}
out := make(map[string]traitRule, len(traits))
for _, tr := range traits {
if err := validateTraitRule(tr); err != nil {
return nil, err
}
out[tr.SourceTable] = tr
}
return out, nil
}
func validateRouteRule(rule routeRule) error {
fields := []string{rule.SourceTable, rule.SourceIDColumn, rule.ProductWarehouseCol, rule.QuantityCol}
for _, value := range fields {
if _, err := mustSafeIdentifier(value); err != nil {
return err
}
}
if rule.UsedQuantityCol != nil {
if _, err := mustSafeIdentifier(*rule.UsedQuantityCol); err != nil {
return err
}
}
if rule.PendingQuantityCol != nil {
if _, err := mustSafeIdentifier(*rule.PendingQuantityCol); err != nil {
return err
}
}
if strings.TrimSpace(rule.LegacyTypeKey) == "" {
return fmt.Errorf("route rule has empty legacy type key")
}
return nil
}
func validateTraitRule(rule traitRule) error {
if _, err := mustSafeIdentifier(rule.SourceTable); err != nil {
return err
}
if _, err := mustSafeIdentifier(rule.DateColumn); err != nil {
return err
}
if _, err := mustSafeIdentifier(rule.IDColumn); err != nil {
return err
}
if rule.DateTable != nil {
if _, err := mustSafeIdentifier(*rule.DateTable); err != nil {
return err
}
if rule.DateJoinLeftCol == nil || rule.DateJoinRightCol == nil {
return fmt.Errorf("trait %s requires date join columns", rule.SourceTable)
}
if _, err := mustSafeIdentifier(*rule.DateJoinLeftCol); err != nil {
return err
}
if _, err := mustSafeIdentifier(*rule.DateJoinRightCol); err != nil {
return err
}
}
if rule.FallbackDateColumn != nil {
if _, err := mustSafeIdentifier(*rule.FallbackDateColumn); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,8 @@
package fifo_stock_v2
import "errors"
var (
ErrInvalidRequest = errors.New("invalid fifo stock v2 request")
ErrInsufficientStock = errors.New("insufficient stock")
)
@@ -0,0 +1,293 @@
package fifo_stock_v2
import (
"context"
"fmt"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type gatherSQLRow struct {
SourceTable string `gorm:"column:source_table"`
LegacyTypeKey string `gorm:"column:legacy_type_key"`
FunctionCode string `gorm:"column:function_code"`
SourceID uint `gorm:"column:source_id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
SortAt time.Time `gorm:"column:sort_at"`
SortPriority int `gorm:"column:sort_priority"`
Quantity float64 `gorm:"column:quantity"`
UsedQuantity float64 `gorm:"column:used_quantity"`
PendingQuantity float64 `gorm:"column:pending_quantity"`
AvailableQuantity float64 `gorm:"column:available_quantity"`
}
func (s *fifoStockV2Service) Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error) {
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
return nil, fmt.Errorf("%w: flag group and product warehouse are required", ErrInvalidRequest)
}
if req.Lane != LaneStockable && req.Lane != LaneUsable {
return nil, fmt.Errorf("%w: unsupported lane %q", ErrInvalidRequest, req.Lane)
}
var out []GatherRow
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
rows, err := s.gatherRows(ctx, tx, req)
if err != nil {
return err
}
out = rows
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
func (s *fifoStockV2Service) gatherRows(ctx context.Context, tx *gorm.DB, req GatherRequest) ([]GatherRow, error) {
req.AllocationPurpose = normalizeAllocationPurpose(req.AllocationPurpose)
rules, err := s.loadRouteRules(ctx, tx, req.FlagGroupCode, req.Lane)
if err != nil {
return nil, err
}
if len(rules) == 0 {
return []GatherRow{}, nil
}
tables := make([]string, 0, len(rules))
for _, rule := range rules {
tables = append(tables, rule.SourceTable)
}
traits, err := s.loadTraitMap(ctx, tx, req.Lane, tables)
if err != nil {
return nil, err
}
subqueries := make([]string, 0, len(rules))
args := make([]any, 0, len(rules)*10)
for _, rule := range rules {
trait, ok := traits[rule.SourceTable]
if !ok {
return nil, fmt.Errorf("missing trait for table %s lane %s", rule.SourceTable, req.Lane)
}
subSQL, subArgs, err := s.buildGatherSubquery(rule, trait, req)
if err != nil {
return nil, err
}
subqueries = append(subqueries, subSQL)
args = append(args, subArgs...)
}
if len(subqueries) == 0 {
return []GatherRow{}, nil
}
limit := req.Limit
if limit <= 0 {
limit = s.defaultGatherLimit
}
if limit <= 0 {
limit = 1000
}
query := "SELECT * FROM (" + strings.Join(subqueries, " UNION ALL ") + ") AS g"
if req.AfterSortAt != nil {
query += `
WHERE
(g.sort_at > ?)
OR (g.sort_at = ? AND g.source_table > ?)
OR (g.sort_at = ? AND g.source_table = ? AND g.source_id > ?)
`
args = append(args,
*req.AfterSortAt,
*req.AfterSortAt, req.AfterSourceTable,
*req.AfterSortAt, req.AfterSourceTable, req.AfterSourceID,
)
}
query += " ORDER BY g.sort_at ASC, g.sort_priority ASC, g.source_table ASC, g.source_id ASC LIMIT ?"
args = append(args, limit)
var rows []gatherSQLRow
if err := tx.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]GatherRow, 0, len(rows))
for _, row := range rows {
out = append(out, GatherRow{
Ref: Ref{
Table: row.SourceTable,
ID: row.SourceID,
LegacyTypeKey: row.LegacyTypeKey,
FunctionCode: row.FunctionCode,
},
FlagGroupCode: req.FlagGroupCode,
ProductWarehouseID: row.ProductWarehouseID,
SortAt: row.SortAt,
SortPriority: row.SortPriority,
Quantity: row.Quantity,
UsedQuantity: row.UsedQuantity,
PendingQuantity: row.PendingQuantity,
AvailableQuantity: row.AvailableQuantity,
SourceTable: row.SourceTable,
SourceID: row.SourceID,
})
}
return out, nil
}
func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule, req GatherRequest) (string, []any, error) {
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
productWarehouseCol, _ := mustSafeIdentifier(rule.ProductWarehouseCol)
quantityCol, _ := mustSafeIdentifier(rule.QuantityCol)
baseQtyExpr := fmt.Sprintf("COALESCE(src.%s,0)::numeric", quantityCol)
usedExpr := "0::numeric"
pendingExpr := "0::numeric"
availableExpr := baseQtyExpr
extraArgs := make([]any, 0, 2)
whereExtraArgs := make([]any, 0, 1)
if req.Lane == LaneStockable {
if !req.IgnoreSourceUsed && rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" {
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol)
} else {
// NOTE:
// usedExpr is referenced twice in the generated SELECT:
// 1) as used_quantity
// 2) inside available_quantity = base - usedExpr
// plus once in stockable WHERE clause via availableExpr > 0.
// We split the args because the WHERE placeholder order appears
// after product/flag filter placeholders in the final SQL.
usedExpr = fmt.Sprintf(
"(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s' AND sa.allocation_purpose = ?)",
sourceIDCol,
activeAllocationStatus(),
)
extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
}
availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr)
} else {
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
pendingExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", pendingCol)
}
availableExpr = baseQtyExpr
}
sortExpr, joinClause, err := buildSortExpr(trait)
if err != nil {
return "", nil, err
}
functionCodeExpr := "?::text"
functionCodeArgs := []any{rule.FunctionCode}
if rule.SourceTable == "adjustment_stocks" {
functionCodeExpr = "COALESCE(NULLIF(src.function_code,''), ?::text)"
}
whereParts := []string{
fmt.Sprintf("src.%s = ?", productWarehouseCol),
fmt.Sprintf(`EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_type = ? AND 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 = src.%s AND fm.flag_group_code = ?
)`, productWarehouseCol),
}
if req.Lane == LaneStockable {
whereParts = append(whereParts, fmt.Sprintf("%s > 0", availableExpr))
}
if req.AsOf != nil {
whereParts = append(whereParts, fmt.Sprintf("%s <= ?", sortExpr))
}
if req.From != nil {
whereParts = append(whereParts, fmt.Sprintf("%s >= ?", sortExpr))
}
if rule.ScopeSQL != nil && strings.TrimSpace(*rule.ScopeSQL) != "" {
whereParts = append(whereParts, fmt.Sprintf("(%s)", normalizeScopeSQL(*rule.ScopeSQL)))
}
subquery := fmt.Sprintf(`
SELECT
?::text AS source_table,
?::text AS legacy_type_key,
%s AS function_code,
src.%s AS source_id,
src.%s AS product_warehouse_id,
%s AS sort_at,
?::int AS sort_priority,
%s AS quantity,
%s AS used_quantity,
%s AS pending_quantity,
%s AS available_quantity
FROM %s src
%s
WHERE %s
`, functionCodeExpr, sourceIDCol, productWarehouseCol, sortExpr, baseQtyExpr, usedExpr, pendingExpr, availableExpr, sourceTable, joinClause, strings.Join(whereParts, " AND "))
args := []any{
rule.SourceTable,
rule.LegacyTypeKey,
}
args = append(args, functionCodeArgs...)
args = append(args, trait.SortPriority)
args = append(args, extraArgs...)
args = append(args,
req.ProductWarehouseID,
entity.FlagableTypeProduct,
req.FlagGroupCode,
)
args = append(args, whereExtraArgs...)
if req.AsOf != nil {
args = append(args, *req.AsOf)
}
if req.From != nil {
args = append(args, *req.From)
}
return subquery, args, nil
}
func buildSortExpr(trait traitRule) (string, string, error) {
dateCol, _ := mustSafeIdentifier(trait.DateColumn)
idCol, _ := mustSafeIdentifier(trait.IDColumn)
_ = idCol
joinClause := ""
sortBase := fmt.Sprintf("src.%s", dateCol)
if trait.DateTable != nil && strings.TrimSpace(*trait.DateTable) != "" {
dateTable, _ := mustSafeIdentifier(*trait.DateTable)
if trait.DateJoinLeftCol == nil || trait.DateJoinRightCol == nil {
return "", "", fmt.Errorf("trait %s requires date join columns", trait.SourceTable)
}
leftCol, _ := mustSafeIdentifier(*trait.DateJoinLeftCol)
rightCol, _ := mustSafeIdentifier(*trait.DateJoinRightCol)
joinClause = fmt.Sprintf("LEFT JOIN %s dt ON src.%s = dt.%s", dateTable, leftCol, rightCol)
sortBase = fmt.Sprintf("dt.%s", dateCol)
}
if trait.FallbackDateColumn != nil && strings.TrimSpace(*trait.FallbackDateColumn) != "" {
fallbackCol, _ := mustSafeIdentifier(*trait.FallbackDateColumn)
sortBase = fmt.Sprintf("COALESCE(%s, src.%s)", sortBase, fallbackCol)
}
sortExpr := fmt.Sprintf("COALESCE(%s, '1970-01-01 00:00:00+00'::timestamptz)", sortBase)
return sortExpr, joinClause, nil
}
@@ -0,0 +1,131 @@
package fifo_stock_v2
import (
"context"
"errors"
"math"
"sort"
commonRepo "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/fifo"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
func ReleasePopulationConsumptionByUsable(
ctx context.Context,
tx *gorm.DB,
usableType string,
usableID uint,
) error {
if tx == nil {
return errors.New("transaction is required")
}
if usableType == "" || usableID == 0 {
return errors.New("usable type and id are required")
}
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
allocations, err := stockAllocationRepo.FindActiveByUsable(ctx, usableType, usableID, nil)
if err != nil {
return err
}
for _, allocation := range allocations {
if allocation.StockableType != fifo.StockableKeyProjectFlockPopulation.String() || allocation.StockableId == 0 || allocation.Qty <= 0 {
continue
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", allocation.StockableId).
Update("total_used_qty", gorm.Expr("GREATEST(total_used_qty - ?, 0)", allocation.Qty)).Error; err != nil {
return err
}
}
return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, nil)
}
func AllocatePopulationConsumption(
ctx context.Context,
tx *gorm.DB,
populations []entity.ProjectFlockPopulation,
productWarehouseID uint,
usableType string,
usableID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if productWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak valid")
}
if usableType == "" || usableID == 0 {
return errors.New("usable type and id are required")
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan")
}
if err := ReleasePopulationConsumptionByUsable(ctx, tx, usableType, usableID); err != nil {
return err
}
sort.Slice(populations, func(i, j int) bool {
if populations[i].CreatedAt.Equal(populations[j].CreatedAt) {
return populations[i].Id < populations[j].Id
}
return populations[i].CreatedAt.Before(populations[j].CreatedAt)
})
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
remaining := consumeQty
for _, pop := range populations {
available := pop.TotalQty - pop.TotalUsedQty
if available <= 0 {
continue
}
portion := math.Min(available, remaining)
if portion <= 0 {
continue
}
allocation := &entity.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
StockableId: pop.Id,
UsableType: usableType,
UsableId: usableID,
Qty: portion,
Status: entity.StockAllocationStatusActive,
AllocationPurpose: entity.StockAllocationPurposeConsume,
}
if err := stockAllocationRepo.CreateOne(ctx, allocation, nil); err != nil {
return err
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", pop.Id).
Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil {
return err
}
remaining -= portion
if remaining <= 1e-6 {
break
}
}
if remaining > 1e-6 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi")
}
return nil
}
@@ -0,0 +1,177 @@
package fifo_stock_v2
import (
"context"
"encoding/json"
"fmt"
"math"
"time"
"gorm.io/gorm"
)
func (s *fifoStockV2Service) Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error) {
result := &RecalculateResult{Drifts: make([]WarehouseDrift, 0)}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
hash := requestHash(map[string]any{
"product_warehouse_ids": req.ProductWarehouseIDs,
"flag_group_codes": req.FlagGroupCodes,
"as_of": req.AsOf,
"fix_drift": req.FixDrift,
})
logRow, reused, err := s.beginOperation(
tx,
OperationRecalculate,
req.IdempotencyKey,
hash,
0,
"RECALCULATE",
"",
0,
)
if err != nil {
return err
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent recalculate has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
warehouseIDs, err := s.resolveRecalculateWarehouseIDs(ctx, tx, req.ProductWarehouseIDs)
if err != nil {
return err
}
groupCodes, err := s.resolveRecalculateGroupCodes(ctx, tx, req.FlagGroupCodes)
if err != nil {
return err
}
for _, warehouseID := range warehouseIDs {
expected := 0.0
for _, flagGroup := range groupCodes {
available, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, warehouseID, flagGroup, req.AsOf)
if calcErr != nil {
return calcErr
}
expected += available
}
actual, actualErr := s.loadWarehouseQty(ctx, tx, warehouseID)
if actualErr != nil {
return actualErr
}
delta := expected - actual
result.Checked++
if math.Abs(delta) < 1e-6 {
continue
}
drift := WarehouseDrift{
ProductWarehouseID: warehouseID,
ExpectedQty: expected,
ActualQty: actual,
Delta: delta,
}
result.Drifts = append(result.Drifts, drift)
if req.FixDrift {
if err := s.adjustProductWarehouseQty(tx, warehouseID, delta); err != nil {
return err
}
result.Fixed++
}
}
if err := s.finishOperation(tx, logRow, result); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) resolveRecalculateWarehouseIDs(ctx context.Context, tx *gorm.DB, provided []uint) ([]uint, error) {
if len(provided) > 0 {
return provided, nil
}
var ids []uint
err := tx.WithContext(ctx).Table("product_warehouses").Select("id").Order("id ASC").Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (s *fifoStockV2Service) resolveRecalculateGroupCodes(ctx context.Context, tx *gorm.DB, provided []string) ([]string, error) {
if len(provided) > 0 {
return provided, nil
}
var groups []string
err := tx.WithContext(ctx).
Table("fifo_stock_v2_flag_groups").
Select("code").
Where("is_active = TRUE").
Order("priority ASC, code ASC").
Scan(&groups).Error
if err != nil {
return nil, err
}
return groups, nil
}
func (s *fifoStockV2Service) calculateWarehouseAvailableForGroup(
ctx context.Context,
tx *gorm.DB,
warehouseID uint,
flagGroupCode string,
asOf *time.Time,
) (float64, error) {
rows, err := s.gatherRows(ctx, tx, GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: LaneStockable,
ProductWarehouseID: warehouseID,
AsOf: asOf,
Limit: 50000,
})
if err != nil {
return 0, err
}
total := 0.0
for _, row := range rows {
total += row.AvailableQuantity
}
return total, nil
}
func (s *fifoStockV2Service) loadWarehouseQty(ctx context.Context, tx *gorm.DB, warehouseID uint) (float64, error) {
type row struct {
Qty float64 `gorm:"column:qty"`
}
var out row
err := tx.WithContext(ctx).
Table("product_warehouses").
Select("COALESCE(qty,0) AS qty").
Where("id = ?", warehouseID).
Take(&out).Error
if err != nil {
return 0, err
}
return out.Qty, nil
}

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