Compare commits

..

157 Commits

Author SHA1 Message Date
giovanni 7846487254 add migration for drift stock logs 2026-05-28 20:59:41 +07:00
giovanni 0ed67955a6 new file migration 2026-05-28 19:17:51 +07:00
giovanni 679d835fbb add migration for normalize jamali non aktif to gudang farm jamali 2026-05-28 17:27:46 +07:00
Giovanni Gabriel Septriadi 2da476b276 Merge branch 'development' into 'production'
add excel export for purchase supplier report

See merge request mbugroup/lti-api!563
2026-05-25 08:16:24 +00:00
Giovanni Gabriel Septriadi 3232fc90bb Merge branch 'export/marketing' into 'development'
fix list penjualan and export penjualan dengan qty

See merge request mbugroup/lti-api!562
2026-05-25 07:53:38 +00:00
giovanni ef985b5da5 fix list penjualan and export penjualan dengan qty 2026-05-25 14:50:01 +07:00
Rivaldi A N S 55666c1dcd Merge branch 'feat/export-balance-monitoring' into 'development'
[FEAT][BE] Export Balance Monitoring

See merge request mbugroup/lti-api!561
2026-05-25 07:25:03 +00:00
ValdiANS c107f0f683 feat(reports): add Excel export to balance monitoring endpoint
Add ?export=excel support to GetBalanceMonitoring. Creates a new
repport.balance_monitoring.export.go with a 2-row merged header layout
matching the UI (Penjualan Ayam and Penjualan Telur grouped columns),
a totals row, red styling for negative Saldo Akhir, and frozen panes
below the header rows. Exported data reflects all active query filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:18:47 +07:00
Rivaldi A N S ba8f00a560 Merge branch 'feat/export-report-purchases-per-supplier' into 'development'
[FEAT][BE] Export Report Purchases Per Supplier

See merge request mbugroup/lti-api!560
2026-05-25 04:35:15 +00:00
ValdiANS 65a1282312 add excel export for purchase supplier report
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:25:53 +07:00
Giovanni Gabriel Septriadi 1ca632d838 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!559
2026-05-25 04:03:38 +00:00
Giovanni Gabriel Septriadi f0403e2699 Merge branch 'hot-fix/cikaum' into 'production'
add migration for normalize wrong location pullet cikaum

See merge request mbugroup/lti-api!558
2026-05-23 04:25:29 +00:00
giovanni 3e34da7385 add migration for normalize wrong location pullet cikaum 2026-05-23 11:12:36 +07:00
Giovanni Gabriel Septriadi 8750e2ffec Merge branch 'fix/location-flock' into 'development'
add migration for normalize wrong location pullet cikaum

See merge request mbugroup/lti-api!557
2026-05-23 04:07:29 +00:00
giovanni 3429529162 add migration for normalize wrong location pullet cikaum 2026-05-23 11:06:33 +07:00
Giovanni Gabriel Septriadi 32b8acb9dc Merge branch 'fix/po-a' into 'development'
fix monitoring saldo

See merge request mbugroup/lti-api!556
2026-05-23 02:36:42 +00:00
giovanni 1992005b01 fix monitoring saldo 2026-05-23 09:32:21 +07:00
Giovanni Gabriel Septriadi 0d7a0e30cd Merge branch 'fix/po-a' into 'development'
fix debt supplier ekspedisi only realisasi

See merge request mbugroup/lti-api!555
2026-05-22 12:55:39 +00:00
giovanni b12f563bc4 fix debt supplier ekspedisi only realisasi 2026-05-22 19:54:53 +07:00
Giovanni Gabriel Septriadi d0e7b7aad1 Merge branch 'fix/po-a' into 'development'
fix monitorin saldo without sales order;format date excel po

See merge request mbugroup/lti-api!554
2026-05-22 12:41:05 +00:00
giovanni c676aed371 fix monitorin saldo without sales order;format date excel po 2026-05-22 19:40:05 +07:00
Giovanni Gabriel Septriadi e781115390 Merge branch 'hot-fix/price-adj' into 'production'
hot fit update price adjustment stock

See merge request mbugroup/lti-api!553
2026-05-22 11:50:45 +00:00
Giovanni Gabriel Septriadi 7bbb6a836c Merge branch 'hot-fix/price-adj' into 'development'
Hot fix/price adj

See merge request mbugroup/lti-api!552
2026-05-22 11:41:53 +00:00
giovanni 6bbab2f1d5 hot fit update price adjustment stock 2026-05-22 18:40:52 +07:00
Giovanni Gabriel Septriadi 70546c2302 Merge branch 'fix/monitoring' into 'development'
fix balance monitoring

See merge request mbugroup/lti-api!550
2026-05-22 08:56:31 +00:00
giovanni 6c7d8ac83e fix balance monitoring 2026-05-22 15:55:26 +07:00
Giovanni Gabriel Septriadi 1e48bc8762 Merge branch 'fix/filter-po' into 'development'
fix

See merge request mbugroup/lti-api!549
2026-05-22 06:28:39 +00:00
giovanni 77a30837e2 fix 2026-05-22 13:27:47 +07:00
Giovanni Gabriel Septriadi a63460e853 Merge branch 'fix/filter-po' into 'development'
fix filter

See merge request mbugroup/lti-api!548
2026-05-22 05:31:57 +00:00
giovanni 1be0fa1a5f fix filter 2026-05-22 12:30:23 +07:00
Giovanni Gabriel Septriadi c9e3905a65 Merge branch 'feat/filter' into 'development'
adjust export format purchase and filter

See merge request mbugroup/lti-api!547
2026-05-21 04:49:46 +00:00
giovanni 495f5f5cc1 adjust export format purchase and filter 2026-05-21 11:48:24 +07:00
Giovanni Gabriel Septriadi 71e80634b1 Merge branch 'feat/bop-finance' into 'development'
add vendor ekspedisi to laporan keuangan

See merge request mbugroup/lti-api!546
2026-05-21 01:42:48 +00:00
giovanni af2b3366ba add vendor ekspedisi to laporan keuanga 2026-05-20 23:10:08 +07:00
Giovanni Gabriel Septriadi e015e20b5c Merge branch 'fix/expenses' into 'development'
[FIX][BE]: adjust response get report expense

See merge request mbugroup/lti-api!545
2026-05-20 07:40:50 +00:00
giovanni d92d28c892 adjust response get report expense 2026-05-20 14:39:36 +07:00
Giovanni Gabriel Septriadi 60bdd4a31a Merge branch 'feat/monitoring-saldo' into 'development'
add api monitoring saldo customer

See merge request mbugroup/lti-api!544
2026-05-20 03:35:50 +00:00
Giovanni Gabriel Septriadi cce0d44f83 Merge branch 'feat/lap-keuangan' into 'development'
add export customer payment control

See merge request mbugroup/lti-api!543
2026-05-20 01:49:58 +00:00
giovanni c8623e2f7c add export customer payment control 2026-05-19 22:42:46 +07:00
giovanni 6fc4ad5773 add api monitoring saldo customer 2026-05-19 18:42:57 +07:00
Giovanni Gabriel Septriadi e61625d2f7 Merge branch 'feat/lap-keuangan' into 'development'
[FEAT][BE]: add export laporan keuangan hutang ke supplier

See merge request mbugroup/lti-api!542
2026-05-19 11:41:05 +00:00
giovanni 907b695526 add export laporang keuangan hutang ke supplier 2026-05-19 18:38:58 +07:00
Giovanni Gabriel Septriadi 621d0d2bfd Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!541
2026-05-19 06:48:41 +00:00
Giovanni Gabriel Septriadi 32c34be2c6 Merge branch 'fix/30' into 'development'
[FIX][BE]: fix status marketing when edit sales order and edit marketing delivery

See merge request mbugroup/lti-api!540
2026-05-19 05:16:40 +00:00
giovanni d2aa3ebac7 fix status marketing when edit sales order and edit marketing delivery 2026-05-19 12:15:53 +07:00
Giovanni Gabriel Septriadi 02b86be4c5 Merge branch 'feat/edit-dc' into 'development'
[FIX][BE]: fix detail daily checklist empty kandang; add sorting to report biaya operasional

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

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

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

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

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

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

See merge request mbugroup/lti-api!533
2026-05-13 02:41:24 +00:00
giovanni eac671fa80 adjust calculate total price marketing ayam pullet 2026-05-12 19:55:06 +07:00
Giovanni Gabriel Septriadi 1fd3f96038 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!532
2026-05-12 09:31:19 +00: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 cf0fc9e7e6 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!530
2026-05-11 08:32:04 +00: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
Adnan Zahir d9041a89bb Merge branch 'fix/chickin' into 'production'
[FIX][BE]: add migration for edit chickin_date pullet cikaum 1 dan pullet cikaum 2

See merge request mbugroup/lti-api!518
2026-05-08 15:05:02 +07: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 c75281ebd9 add migration for update day recording pullet cikaum 1 dan 2 2026-05-07 17:35:15 +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
Adnan Zahir ca3ad810c6 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!505
2026-05-05 14:12:22 +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 655b1ad5fe Merge branch 'development' into 'production'
fix: resolve dashboard OpenAPI integration issues

See merge request mbugroup/lti-api!498
2026-05-03 13:08:58 +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 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
Adnan Zahir 84db5fe37a Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!494
2026-04-29 12:53:02 +07:00
Adnan Zahir 63a78da18d Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!480
2026-04-26 00:13:58 +07:00
Adnan Zahir ac50c06cd7 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!478
2026-04-25 15:26:22 +07:00
Adnan Zahir b60649f59d Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!476
2026-04-25 14:16:20 +07:00
Adnan Zahir 6acc9416c1 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!473
2026-04-24 21:24:56 +07:00
Adnan Zahir bb4e5d6e3e Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!469
2026-04-24 14:20:43 +07:00
Adnan Zahir 170c221957 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!467
2026-04-24 13:31:35 +07:00
Adnan Zahir 812327f148 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!461
2026-04-24 12:30:41 +07:00
Adnan Zahir cd192128f1 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!443
2026-04-23 12:38:24 +07:00
Adnan Zahir a5d4d6c11d Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!436
2026-04-22 13:13:06 +07:00
Adnan Zahir 1452f8d083 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!427
2026-04-20 10:16:45 +07:00
Adnan Zahir 33c6706181 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!425
2026-04-20 08:24:44 +07:00
Adnan Zahir c9618e1095 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!422
2026-04-18 09:47:04 +07:00
Adnan Zahir cae7f3ef63 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!414
2026-04-14 12:01:04 +07:00
Adnan Zahir 42793d94bd Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!412
2026-04-13 14:12:48 +07:00
Adnan Zahir 1369bf0e36 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!411
2026-04-11 14:08:35 +07:00
Adnan Zahir 361d14bd3e Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!406
2026-04-10 10:50:07 +07:00
Adnan Zahir 7923352535 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!401
2026-04-07 22:58:38 +07:00
Adnan Zahir 010240066a Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!399
2026-04-07 16:52:45 +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
119 changed files with 8596 additions and 842 deletions
+16 -2
View File
@@ -27,11 +27,25 @@ workflow:
.ecr_login: &ecr_login |
AWS_CLI_ENV_ARGS=""
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}"
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)"
+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
}
+429
View File
@@ -3215,6 +3215,55 @@
]
}
},
"/api/inventory/stock-logs/": {
"get": {
"description": "Read access to `/api/inventory/stock-logs`.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / inventory / stock logs",
"tags": [
"Inventory"
]
}
},
"/api/inventory/transfers/": {
"get": {
"description": "Read access to `/api/inventory/transfers`.",
@@ -4318,6 +4367,29 @@
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": [
{
"created_at": "2026-01-01T00:00:00Z",
"created_user": {
"id": 1,
"name": "Admin"
},
"id": 1,
"name": "FCR Broiler Standard",
"updated_at": "2026-01-01T00:00:00Z"
}
],
"message": "Get all fcrs successfully",
"meta": {
"limit": 10,
"page": 1,
"total_pages": 1,
"total_results": 1
},
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
@@ -4379,6 +4451,41 @@
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": {
"created_at": "2026-01-01T00:00:00Z",
"created_user": {
"id": 1,
"name": "Admin"
},
"fcr_standards": [
{
"fcr_number": 1.2,
"id": 1,
"mortality": 0.5,
"weight": 0.5
},
{
"fcr_number": 1.35,
"id": 2,
"mortality": 0.3,
"weight": 1
},
{
"fcr_number": 1.5,
"id": 3,
"mortality": 0.25,
"weight": 1.5
}
],
"id": 1,
"name": "FCR Broiler Standard",
"updated_at": "2026-01-01T00:00:00Z"
},
"message": "Get fcr successfully",
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
@@ -6457,6 +6564,126 @@
]
}
},
"/api/production/chickins/": {
"get": {
"description": "Read access to `/api/production/chickins`.",
"parameters": [
{
"description": "Page number.",
"example": 1,
"in": "query",
"name": "page",
"required": false,
"schema": {
"type": "integer"
}
},
{
"description": "Page size.",
"example": 10,
"in": "query",
"name": "limit",
"required": false,
"schema": {
"type": "integer"
}
},
{
"description": "Project flock kandang id filter.",
"example": 1,
"in": "query",
"name": "project_flock_kandang_id",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": [
{
"chick_in_date": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"created_user": {
"id": 1,
"name": "Admin"
},
"id": 1,
"notes": "",
"pending_usage_qty": 0,
"product_warehouse": {
"id": 1,
"product": {
"id": 1,
"name": "DOC Broiler"
},
"warehouse": {
"id": 1,
"name": "Gudang DOC"
}
},
"product_warehouse_id": 1,
"project_flock_kandang_id": 1,
"updated_at": "2026-01-01T00:00:00Z",
"usage_qty": 10000
}
],
"message": "Get all chickins successfully",
"meta": {
"limit": 10,
"page": 1,
"total_pages": 1,
"total_results": 1
},
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / production / chickins",
"tags": [
"Production"
]
}
},
"/api/production/chickins/{id}": {
"get": {
"description": "Read access to `/api/production/chickins/:id`.",
@@ -7517,6 +7744,47 @@
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": [
{
"approval": {
"action": null,
"step_name": "Pengajuan",
"step_number": 1
},
"created_at": "2026-01-15T00:00:00Z",
"created_by": 1,
"created_user": {
"id": 1,
"name": "Admin"
},
"economic_cutoff_date": "2026-01-20T00:00:00Z",
"effective_move_date": "2026-01-18T00:00:00Z",
"executed_at": null,
"from_project_flock": {
"flock_name": "Flock A Period 1",
"id": 1
},
"id": 1,
"notes": "",
"to_project_flock": {
"flock_name": "Flock B Period 1",
"id": 2
},
"transfer_date": "2026-01-15T00:00:00Z",
"transfer_number": "TL-00001"
}
],
"message": "Get all transferLayings successfully",
"meta": {
"limit": 10,
"page": 1,
"total_pages": 1,
"total_results": 1
},
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
@@ -7700,6 +7968,69 @@
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": {
"approval": {
"action": null,
"step_name": "Pengajuan",
"step_number": 1
},
"created_at": "2026-01-15T00:00:00Z",
"created_by": 1,
"created_user": {
"id": 1,
"name": "Admin"
},
"economic_cutoff_date": "2026-01-20T00:00:00Z",
"effective_move_date": "2026-01-18T00:00:00Z",
"executed_at": null,
"from_project_flock": {
"flock_name": "Flock A Period 1",
"id": 1
},
"id": 1,
"notes": "",
"sources": [
{
"note": "",
"qty": 5000,
"source_project_flock_kandang": {
"id": 1,
"kandang": {
"id": 1,
"name": "Kandang A"
},
"kandang_id": 1,
"project_flock_id": 1
}
}
],
"targets": [
{
"note": "",
"qty": 5000,
"target_project_flock_kandang": {
"id": 2,
"kandang": {
"id": 2,
"name": "Kandang B"
},
"kandang_id": 2,
"project_flock_id": 2
}
}
],
"to_project_flock": {
"flock_name": "Flock B Period 1",
"id": 2
},
"transfer_date": "2026-01-15T00:00:00Z",
"transfer_number": "TL-00001"
},
"message": "Get transferLaying successfully",
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
@@ -8912,6 +9243,55 @@
]
}
},
"/api/reports/hpp-v2-breakdown": {
"get": {
"description": "Read access to `/api/reports/hpp-v2-breakdown`.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / reports / hpp v2 breakdown",
"tags": [
"Reports"
]
}
},
"/api/reports/marketing": {
"get": {
"description": "Read access to `/api/reports/marketing`.",
@@ -9555,6 +9935,55 @@
]
}
},
"/api/system-settings/": {
"get": {
"description": "Read access to `/api/system-settings`.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / system settings",
"tags": [
"API"
]
}
},
"/api/users/": {
"get": {
"description": "Read access to `/api/users`.",
+285
View File
@@ -2006,6 +2006,34 @@ paths:
summary: GET api / inventory / product warehouses / :id
tags:
- Inventory
/api/inventory/stock-logs/:
get:
description: Read access to `/api/inventory/stock-logs`.
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / inventory / stock logs
tags:
- Inventory
/api/inventory/transfers/:
get:
description: Read access to `/api/inventory/transfers`.
@@ -2686,6 +2714,23 @@ paths:
"200":
content:
application/json:
example:
code: 200
data:
- created_at: "2026-01-01T00:00:00Z"
created_user:
id: 1
name: Admin
id: 1
name: FCR Broiler Standard
updated_at: "2026-01-01T00:00:00Z"
message: Get all fcrs successfully
meta:
limit: 10
page: 1
total_pages: 1
total_results: 1
status: success
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
@@ -2722,6 +2767,31 @@ paths:
"200":
content:
application/json:
example:
code: 200
data:
created_at: "2026-01-01T00:00:00Z"
created_user:
id: 1
name: Admin
fcr_standards:
- fcr_number: 1.2
id: 1
mortality: 0.5
weight: 0.5
- fcr_number: 1.35
id: 2
mortality: 0.3
weight: 1
- fcr_number: 1.5
id: 3
mortality: 0.25
weight: 1.5
id: 1
name: FCR Broiler Standard
updated_at: "2026-01-01T00:00:00Z"
message: Get fcr successfully
status: success
schema:
$ref: '#/components/schemas/SuccessEnvelope'
description: Successful response
@@ -3994,6 +4064,86 @@ paths:
summary: GET api / master data / warehouses / :id
tags:
- Master Data
/api/production/chickins/:
get:
description: Read access to `/api/production/chickins`.
parameters:
- description: Page number.
example: 1
in: query
name: page
required: false
schema:
type: integer
- description: Page size.
example: 10
in: query
name: limit
required: false
schema:
type: integer
- description: Project flock kandang id filter.
example: 1
in: query
name: project_flock_kandang_id
required: false
schema:
type: integer
responses:
"200":
content:
application/json:
example:
code: 200
data:
- chick_in_date: "2026-01-01T00:00:00Z"
created_at: "2026-01-01T00:00:00Z"
created_user:
id: 1
name: Admin
id: 1
notes: ""
pending_usage_qty: 0
product_warehouse:
id: 1
product:
id: 1
name: DOC Broiler
warehouse:
id: 1
name: Gudang DOC
product_warehouse_id: 1
project_flock_kandang_id: 1
updated_at: "2026-01-01T00:00:00Z"
usage_qty: 10000
message: Get all chickins successfully
meta:
limit: 10
page: 1
total_pages: 1
total_results: 1
status: success
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / production / chickins
tags:
- Production
/api/production/chickins/{id}:
get:
description: Read access to `/api/production/chickins/:id`.
@@ -4664,6 +4814,38 @@ paths:
"200":
content:
application/json:
example:
code: 200
data:
- approval:
action: null
step_name: Pengajuan
step_number: 1
created_at: "2026-01-15T00:00:00Z"
created_by: 1
created_user:
id: 1
name: Admin
economic_cutoff_date: "2026-01-20T00:00:00Z"
effective_move_date: "2026-01-18T00:00:00Z"
executed_at: null
from_project_flock:
flock_name: Flock A Period 1
id: 1
id: 1
notes: ""
to_project_flock:
flock_name: Flock B Period 1
id: 2
transfer_date: "2026-01-15T00:00:00Z"
transfer_number: TL-00001
message: Get all transferLayings successfully
meta:
limit: 10
page: 1
total_pages: 1
total_results: 1
status: success
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
@@ -4700,6 +4882,53 @@ paths:
"200":
content:
application/json:
example:
code: 200
data:
approval:
action: null
step_name: Pengajuan
step_number: 1
created_at: "2026-01-15T00:00:00Z"
created_by: 1
created_user:
id: 1
name: Admin
economic_cutoff_date: "2026-01-20T00:00:00Z"
effective_move_date: "2026-01-18T00:00:00Z"
executed_at: null
from_project_flock:
flock_name: Flock A Period 1
id: 1
id: 1
notes: ""
sources:
- note: ""
qty: 5000
source_project_flock_kandang:
id: 1
kandang:
id: 1
name: Kandang A
kandang_id: 1
project_flock_id: 1
targets:
- note: ""
qty: 5000
target_project_flock_kandang:
id: 2
kandang:
id: 2
name: Kandang B
kandang_id: 2
project_flock_id: 2
to_project_flock:
flock_name: Flock B Period 1
id: 2
transfer_date: "2026-01-15T00:00:00Z"
transfer_number: TL-00001
message: Get transferLaying successfully
status: success
schema:
$ref: '#/components/schemas/SuccessEnvelope'
description: Successful response
@@ -5545,6 +5774,34 @@ paths:
summary: GET api / reports / hpp per kandang
tags:
- Reports
/api/reports/hpp-v2-breakdown:
get:
description: Read access to `/api/reports/hpp-v2-breakdown`.
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / reports / hpp v2 breakdown
tags:
- Reports
/api/reports/marketing:
get:
description: Read access to `/api/reports/marketing`.
@@ -5955,6 +6212,34 @@ paths:
summary: GET api / sso / userinfo
tags:
- SSO
/api/system-settings/:
get:
description: Read access to `/api/system-settings`.
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / system settings
tags:
- API
/api/users/:
get:
description: Read access to `/api/users`.
+52
View File
@@ -109,6 +109,19 @@
"method": "GET",
"url": "{{base_url}}/api/closings/?page=1\u0026limit=10\u0026search=kandang\u0026project_status=1\u0026location_id={{location_id}}"
}
},
{
"name": "GET api / system settings",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/system-settings/"
}
}
],
"name": "API"
@@ -582,6 +595,19 @@
"url": "{{base_url}}/api/inventory/product-warehouses/{{id}}"
}
},
{
"name": "GET api / inventory / stock logs",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/inventory/stock-logs/"
}
},
{
"name": "GET api / inventory / transfers",
"request": {
@@ -1143,6 +1169,19 @@
},
{
"item": [
{
"name": "GET api / production / chickins",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/production/chickins/?page=1\u0026limit=10\u0026project_flock_kandang_id={{project_flock_kandang_id}}"
}
},
{
"name": "GET api / production / chickins / :id",
"request": {
@@ -1478,6 +1517,19 @@
"url": "{{base_url}}/api/reports/hpp-per-kandang?page=1\u0026limit=10\u0026period=2026-01-01\u0026show_unrecorded=false\u0026area_id=1,2\u0026location_id=1,2\u0026kandang_id=1,2\u0026weight_min=1.2\u0026weight_max=1.8"
}
},
{
"name": "GET api / reports / hpp v2 breakdown",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/reports/hpp-v2-breakdown"
}
},
{
"name": "GET api / reports / marketing",
"request": {
+1
View File
@@ -89,5 +89,6 @@ func DefaultDashboardPermissions() []string {
"lti.users.detail",
"lti.users.list",
"lti.daily_checklist.master_data.kandang",
"lti.production.chickins.list",
}
}
@@ -31,7 +31,7 @@ func FlockAgeDay(originDate time.Time, periodDate time.Time) int {
if period.Before(origin) {
return 0
}
return int(period.Sub(origin).Hours()/24) + 1
return int(period.Sub(origin).Hours() / 24)
}
func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int {
@@ -0,0 +1,9 @@
BEGIN;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
ALTER TABLE daily_checklists
ADD CONSTRAINT daily_checklists_date_kandang_category_key
UNIQUE (date, kandang_id, category);
COMMIT;
@@ -0,0 +1,10 @@
BEGIN;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected
ON daily_checklists (date, kandang_id, category)
WHERE (status IS NULL OR status <> 'REJECTED');
COMMIT;
@@ -0,0 +1,21 @@
UPDATE recordings r
SET day = (
SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int + 1
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
)
WHERE r.deleted_at IS NULL
AND (
SELECT MIN(pc.chick_in_date)
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
) IS NOT NULL;
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 1);
@@ -0,0 +1,21 @@
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 0);
UPDATE recordings r
SET day = (
SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
)
WHERE r.deleted_at IS NULL
AND (
SELECT MIN(pc.chick_in_date)
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
) IS NOT NULL;
@@ -0,0 +1,13 @@
-- Revert chick_in_date Pullet Cikaum 1 (project_flock_kandang_id = 70) -> 23 Maret 2026
UPDATE public.project_chickins
SET chick_in_date = DATE '2026-03-23',
updated_at = NOW()
WHERE project_flock_kandang_id = 70
AND deleted_at IS NULL;
-- Revert chick_in_date Pullet Cikaum 2 (project_flock_kandang_id = 71) -> 15 Desember 2025
UPDATE public.project_chickins
SET chick_in_date = DATE '2025-12-15',
updated_at = NOW()
WHERE project_flock_kandang_id = 71
AND deleted_at IS NULL;
@@ -0,0 +1,13 @@
-- Update chick_in_date Pullet Cikaum 1 (project_flock_kandang_id = 70) -> 24 Maret 2026
UPDATE public.project_chickins
SET chick_in_date = DATE '2026-03-24',
updated_at = NOW()
WHERE project_flock_kandang_id = 70
AND deleted_at IS NULL;
-- Update chick_in_date Pullet Cikaum 2 (project_flock_kandang_id = 71) -> 6 April 2026
UPDATE public.project_chickins
SET chick_in_date = DATE '2026-04-06',
updated_at = NOW()
WHERE project_flock_kandang_id = 71
AND deleted_at IS NULL;
@@ -0,0 +1,21 @@
-- Revert: hitung ulang recording.day menggunakan chick_in_date sebelum perubahan
-- PFK 70: old chick_in_date = 2026-03-23
-- PFK 71: old chick_in_date = 2025-12-15
-- Kembalikan constraint chk_recordings_day ke >= 1
UPDATE recordings r
SET day = GREATEST(1, (r.record_datetime::date -
CASE r.project_flock_kandangs_id
WHEN 70 THEN DATE '2026-03-23'
WHEN 71 THEN DATE '2025-12-15'
END)::int + 1),
updated_at = NOW()
WHERE r.project_flock_kandangs_id IN (70, 71)
AND r.deleted_at IS NULL;
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 1);
@@ -0,0 +1,23 @@
-- Normalize recording.day untuk Pullet Cikaum 1 & 2
-- Setelah migrasi 20260505083754_update_pullet_cikaum_chick_in_date mengubah chick_in_date:
-- PFK 70: 2026-03-23 → 2026-03-24 (shift +1 hari)
-- PFK 71: 2025-12-15 → 2026-04-06 (shift +112 hari)
-- Recording.day perlu dihitung ulang: day = record_datetime::date - chick_in_date::date
-- Edge case: PFK 70 punya 1 recording (2026-03-23) sebelum chick_in_date baru → di-clamp ke 0
-- Note: constraint chk_recordings_day diubah ke >= 0 karena zero-indexed day
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 0);
UPDATE recordings r
SET day = GREATEST(0, (r.record_datetime::date - pc.chick_in_date::date)::int),
updated_at = NOW()
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
AND r.deleted_at IS NULL
AND r.project_flock_kandangs_id IN (70, 71);
@@ -0,0 +1,5 @@
-- Rollback price adjustment_stock id=531
UPDATE adjustment_stocks
SET price = 9535,
grand_total = ROUND(9000 * 9535, 3)
WHERE id = 531 AND adj_number = 'ADJ-00506';
@@ -0,0 +1,7 @@
-- Fix price adjustment_stock id=531 (ADJ-00506)
-- Old: price=9535, grand_total=85,815,000
-- New: price=12635, grand_total=113,715,000
UPDATE adjustment_stocks
SET price = 12635,
grand_total = ROUND(9000 * 12635, 3)
WHERE id = 531 AND adj_number = 'ADJ-00506';
@@ -0,0 +1 @@
ALTER TABLE expenses DROP COLUMN is_paid;
@@ -0,0 +1 @@
ALTER TABLE expenses ADD COLUMN is_paid BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,5 @@
BEGIN;
DROP TABLE IF EXISTS daily_checklist_empty_kandangs;
COMMIT;
@@ -0,0 +1,60 @@
BEGIN;
CREATE TABLE daily_checklist_empty_kandangs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
daily_checklist_id bigint NOT NULL,
kandang_id bigint NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
created_by bigint,
deleted_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT fk_dcek_daily_checklist
FOREIGN KEY (daily_checklist_id) REFERENCES daily_checklists(id) ON DELETE CASCADE,
CONSTRAINT fk_dcek_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE,
CONSTRAINT fk_dcek_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_dcek_deleted_by
FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT ck_dcek_range CHECK (end_date >= start_date)
);
CREATE INDEX idx_dcek_kandang_range
ON daily_checklist_empty_kandangs (kandang_id, start_date, end_date)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_dcek_daily_checklist_unique
ON daily_checklist_empty_kandangs (daily_checklist_id)
WHERE deleted_at IS NULL;
INSERT INTO daily_checklist_empty_kandangs (
daily_checklist_id, kandang_id, start_date, end_date, created_by, created_at, updated_at
)
SELECT
dc.id,
dc.kandang_id,
dc.date AS start_date,
COALESCE(
(SELECT (next_dc.date - INTERVAL '1 day')::date
FROM daily_checklists next_dc
WHERE next_dc.kandang_id = dc.kandang_id
AND next_dc.date > dc.date
AND next_dc.category <> 'empty_kandang'
AND (next_dc.status IS NULL OR next_dc.status <> 'REJECTED')
AND next_dc.deleted_at IS NULL
ORDER BY next_dc.date ASC
LIMIT 1),
dc.date
) AS end_date,
dc.created_by,
dc.created_at,
dc.updated_at
FROM daily_checklists dc
WHERE dc.category = 'empty_kandang'
AND dc.deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,2 @@
ALTER TABLE customers DROP COLUMN bank_name;
ALTER TABLE suppliers DROP COLUMN bank_name;
@@ -0,0 +1,2 @@
ALTER TABLE customers ADD COLUMN bank_name VARCHAR(100) NOT NULL DEFAULT '';
ALTER TABLE suppliers ADD COLUMN bank_name VARCHAR(100);
@@ -0,0 +1,4 @@
UPDATE adjustment_stocks
SET price = 9535,
grand_total = ROUND(8700 * 9535, 3)
WHERE id = 532 AND adj_number = 'ADJ-00507';
@@ -0,0 +1,5 @@
UPDATE adjustment_stocks
SET price = 12635,
grand_total = ROUND(8700 * 12635, 3)
WHERE id = 532 AND adj_number = 'ADJ-00507';
@@ -0,0 +1,31 @@
BEGIN;
-- Rollback konsolidasi: kembalikan data ke loc 18 / 25 sesuai snapshot pre-migration.
-- Order: un-soft-delete locations dulu agar FK tidak gagal saat UPDATE child.
-- 1. Un-soft-delete locations
UPDATE locations SET deleted_at = NULL WHERE id IN (18, 25);
-- 2. project_flocks: PF 30 -> 18, PF 25 & 31 -> 25
UPDATE project_flocks SET location_id = 18, updated_at = NOW() WHERE id = 30;
UPDATE project_flocks SET location_id = 25, updated_at = NOW() WHERE id IN (25, 31);
-- 3. kandangs: K9, K72, K117 -> 18; K10, K73, K116 -> 25
UPDATE kandangs SET location_id = 18, updated_at = NOW() WHERE id IN (9, 72, 117);
UPDATE kandangs SET location_id = 25, updated_at = NOW() WHERE id IN (10, 73, 116);
-- 4. kandang_groups: KG 26, 68 -> 18; KG 27, 67 -> 25
UPDATE kandang_groups SET location_id = 18, updated_at = NOW() WHERE id IN (26, 68);
UPDATE kandang_groups SET location_id = 25, updated_at = NOW() WHERE id IN (27, 67);
-- 5. warehouses: W27, W145, W152 -> 18; W3, W146, W153 -> 25
UPDATE warehouses SET location_id = 18, updated_at = NOW() WHERE id IN (27, 145, 152);
UPDATE warehouses SET location_id = 25, updated_at = NOW() WHERE id IN (3, 146, 153);
-- 6. expenses: list eksplisit per location
UPDATE expenses SET location_id = 18, updated_at = NOW()
WHERE id IN (36, 345, 500, 501, 502, 503, 504, 505, 506, 507, 508);
UPDATE expenses SET location_id = 25, updated_at = NOW()
WHERE id IN (9, 37, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518);
COMMIT;
@@ -0,0 +1,34 @@
BEGIN;
-- Konsolidasi 3 lokasi "Pullet Cikaum" jadi 1.
-- Pindahkan semua data di loc 18 (Pullet Cikaum 1) & 25 (Pullet Cikaum 2) ke loc 2 (Pullet Cikaum).
-- Urutan wajib: semua UPDATE child harus selesai SEBELUM soft-delete locations,
-- karena trigger trg_soft_delete_fk_locations akan RAISE EXCEPTION untuk FK
-- RESTRICT (project_flocks, kandangs, kandang_groups, expenses) atau SET NULL
-- untuk warehouses kalau masih ada child yang reference.
-- 1. project_flocks (PF 25, 30, 31)
UPDATE project_flocks SET location_id = 2, updated_at = NOW()
WHERE location_id IN (18, 25);
-- 2. kandangs (K9, K72, K117, K10, K73, K116)
UPDATE kandangs SET location_id = 2, updated_at = NOW()
WHERE location_id IN (18, 25);
-- 3. kandang_groups (KG 26, 68, 27, 67)
UPDATE kandang_groups SET location_id = 2, updated_at = NOW()
WHERE location_id IN (18, 25);
-- 4. warehouses (W3, W27, W145, W146, W152, W153)
UPDATE warehouses SET location_id = 2, updated_at = NOW()
WHERE location_id IN (18, 25);
-- 5. expenses (23 row BOP)
UPDATE expenses SET location_id = 2, updated_at = NOW()
WHERE location_id IN (18, 25);
-- 6. Soft-delete locations 18 & 25 (kosong, aman karena semua child sudah pindah)
UPDATE locations SET deleted_at = NOW()
WHERE id IN (18, 25) AND deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,99 @@
BEGIN;
-- ============================================================
-- Rollback dynamic via audit snapshots di schema `migration_audit.jamali_w10_*`.
-- Semua reverse dibaca dari snapshot yang dibuat oleh UP migration —
-- tidak ada IDs/qty yang hardcode. Robust terhadap data drift antara
-- dump time dan UP apply time (misalnya row baru warehouse_id=10
-- yang muncul setelah dump diambil).
--
-- LIMITASI: FK relinks di stock_logs / stock_allocations / recording_eggs /
-- marketing_products / dll. TIDAK direverse di sini (skip audit per-row
-- untuk hemat storage ~40MB). Setelah down, 9 PW W10 yang di-restore
-- akan kosong dari child rows (semua child masih pointing ke W25 PW
-- yang sebelumnya menerima merge). Untuk rollback penuh, restore DB
-- dari backup pre-migration.
-- ============================================================
-- Guard: pastikan audit tables ada (kalau tidak, fail-loud)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'migration_audit'
AND table_name = 'jamali_w10_pw_deleted_snapshot'
) THEN
RAISE EXCEPTION 'Audit table migration_audit.jamali_w10_* tidak ditemukan. UP migration belum dijalankan atau audit sudah di-drop. Restore dari DB backup jika perlu.';
END IF;
END $$;
-- 1. Un-soft-delete warehouse 10 (kalau memang di-softdelete oleh UP)
UPDATE warehouses w
SET deleted_at = NULL, updated_at = NOW()
FROM migration_audit.jamali_w10_warehouse_softdeleted a
WHERE w.id = a.id;
-- 2. Un-soft-delete stock_transfers self-loop yang disoft-delete UP step 7.1
UPDATE stock_transfers st
SET deleted_at = NULL, updated_at = NOW()
FROM migration_audit.jamali_w10_st_softdeleted a
WHERE st.id = a.id;
-- 3. Reverse stock_transfers redirect (CASE-based dari snapshot was_from_w10/was_to_w10)
UPDATE stock_transfers st
SET from_warehouse_id = CASE WHEN a.was_from_w10 THEN 10 ELSE st.from_warehouse_id END,
to_warehouse_id = CASE WHEN a.was_to_w10 THEN 10 ELSE st.to_warehouse_id END,
updated_at = NOW()
FROM migration_audit.jamali_w10_st_redirected a
WHERE st.id = a.id;
-- 3b. Self-loop transfers (W10<->W25 awal) juga punya from_warehouse_id=25 atau
-- to_warehouse_id=25 setelah UP step 7.2. Karena snapshot jamali_w10_st_softdeleted
-- punya kolom from_warehouse_id & to_warehouse_id asli, pakai itu untuk reverse.
UPDATE stock_transfers st
SET from_warehouse_id = 10, updated_at = NOW()
FROM migration_audit.jamali_w10_st_softdeleted a
WHERE st.id = a.id AND a.from_warehouse_id = 10;
UPDATE stock_transfers st
SET to_warehouse_id = 10, updated_at = NOW()
FROM migration_audit.jamali_w10_st_softdeleted a
WHERE st.id = a.id AND a.to_warehouse_id = 10;
-- 4. Reverse purchase_items.warehouse_id 25 -> 10
UPDATE purchase_items
SET warehouse_id = 10
WHERE id IN (SELECT id FROM migration_audit.jamali_w10_purchase_items);
-- 5. Reverse W10-only PW (warehouse_id 25 -> 10, restore pfk asli dari snapshot)
UPDATE product_warehouses pw
SET warehouse_id = 10, project_flock_kandang_id = a.original_pfk
FROM migration_audit.jamali_w10_pw_w10only_snapshot a
WHERE pw.id = a.id;
-- 6. Subtract qty dari W25 PW (reverse merge)
-- WARNING: kalau W25 qty sudah dikonsumsi pasca-UP (sales/recording/dll),
-- hasil bisa negatif. Tidak ada CHECK constraint di product_warehouses.qty,
-- jadi silent. Operator harus verifikasi manual post-down:
-- SELECT id, qty FROM product_warehouses WHERE qty < 0;
UPDATE product_warehouses pw
SET qty = pw.qty - a.merged_qty
FROM migration_audit.jamali_w10_qty_merge a
WHERE pw.id = a.target_pw_id;
-- 7. Re-INSERT 9 W10 PW rows yang di-DELETE oleh UP (PK asli + qty asli)
INSERT INTO product_warehouses (id, product_id, warehouse_id, qty, project_flock_kandang_id)
SELECT id, product_id, 10, qty, project_flock_kandang_id
FROM migration_audit.jamali_w10_pw_deleted_snapshot;
-- 8. Cleanup audit tables (drop satu per satu, tidak wildcard untuk safety)
DROP TABLE migration_audit.jamali_w10_pw_deleted_snapshot;
DROP TABLE migration_audit.jamali_w10_qty_merge;
DROP TABLE migration_audit.jamali_w10_pw_w10only_snapshot;
DROP TABLE migration_audit.jamali_w10_st_softdeleted;
DROP TABLE migration_audit.jamali_w10_st_redirected;
DROP TABLE migration_audit.jamali_w10_purchase_items;
DROP TABLE migration_audit.jamali_w10_warehouse_softdeleted;
-- Schema migration_audit dipertahankan (bisa dipakai migration lain di masa depan)
COMMIT;
@@ -0,0 +1,241 @@
BEGIN;
-- ============================================================
-- Normalisasi warehouse 10 (Jamali NON_AKTIF) -> 25 (Gudang Farm Jamali)
-- Background: Dua warehouse LOKASI di area & lokasi sama (area_id=6,
-- location_id=16). W10 sudah ditandai NON_AKTIF tapi masih punya 13
-- product_warehouses, 3,590 stock_logs, ~790K stock_allocations,
-- 332 marketing_products, 17 purchase_items, dan 14 stock_transfers.
-- Migration ini konsolidasikan semua relasi ke W25 lalu soft-delete W10.
--
-- Klasifikasi data:
-- A. 9 product_warehouses W10 overlap dengan W25 (sama product_id, pfk=NULL)
-- -> merge qty ke W25, relink semua FK ke product_warehouses.id,
-- lalu DELETE W10 PW rows.
-- B. 4 product_warehouses W10-only -> UPDATE warehouse_id=25.
-- Rows 1188/1189/1190 punya pfk=98 (anomali LOKASI, seharusnya NULL
-- per aturan di CLAUDE.md [2026-05-06]) -> normalisasi sekalian.
-- C. 17 purchase_items.warehouse_id=10 -> UPDATE 25 (no unique conflict).
-- D. 3 stock_transfers W10<->W25 (PND-LTI-00107/00109/00119) akan jadi
-- self-loop W25<->W25 setelah merge -> soft-delete.
-- E. 12 stock_transfers EGG_FARM_CUTOVER to_warehouse_id=10 -> UPDATE 25.
-- F. warehouse_id=10 sendiri -> soft-delete.
--
-- UP membuat 7 snapshot table di schema `migration_audit.jamali_w10_*`
-- sebelum mutasi. DOWN baca snapshot itu untuk reverse dynamic (tidak
-- hardcode IDs/qty), sehingga apapun yang ada di production saat UP
-- dijalankan akan ter-audit dan ter-reverse. FK relinks
-- (stock_logs/stock_allocations/dll) TIDAK di-audit (storage ~40MB)
-- — limitation: tidak bisa di-reverse DOWN, full rollback = DB backup.
-- ============================================================
-- STEP -1: Buat schema audit + snapshot tables (idempotent rerun via DROP IF EXISTS)
CREATE SCHEMA IF NOT EXISTS migration_audit;
DROP TABLE IF EXISTS migration_audit.jamali_w10_pw_deleted_snapshot;
DROP TABLE IF EXISTS migration_audit.jamali_w10_qty_merge;
DROP TABLE IF EXISTS migration_audit.jamali_w10_pw_w10only_snapshot;
DROP TABLE IF EXISTS migration_audit.jamali_w10_st_softdeleted;
DROP TABLE IF EXISTS migration_audit.jamali_w10_st_redirected;
DROP TABLE IF EXISTS migration_audit.jamali_w10_purchase_items;
DROP TABLE IF EXISTS migration_audit.jamali_w10_warehouse_softdeleted;
-- Snapshot 9 W10 PW yang akan di-DELETE (overlap dgn W25, pfk=NULL)
CREATE TABLE migration_audit.jamali_w10_pw_deleted_snapshot AS
SELECT pw10.id, pw10.product_id, pw10.qty, pw10.project_flock_kandang_id
FROM product_warehouses pw10
JOIN product_warehouses pw25
ON pw25.product_id = pw10.product_id
AND pw25.warehouse_id = 25
AND pw25.project_flock_kandang_id IS NULL
WHERE pw10.warehouse_id = 10 AND pw10.project_flock_kandang_id IS NULL;
-- Snapshot qty delta per W25 target (untuk reverse subtract)
CREATE TABLE migration_audit.jamali_w10_qty_merge AS
SELECT pw25.id AS target_pw_id, pw10.id AS source_pw_id, pw10.qty AS merged_qty
FROM product_warehouses pw10
JOIN product_warehouses pw25
ON pw25.product_id = pw10.product_id
AND pw25.warehouse_id = 25
AND pw25.project_flock_kandang_id IS NULL
WHERE pw10.warehouse_id = 10 AND pw10.project_flock_kandang_id IS NULL;
-- Snapshot W10-only PW (yang akan di-UPDATE warehouse_id 10->25)
CREATE TABLE migration_audit.jamali_w10_pw_w10only_snapshot AS
SELECT pw10.id, pw10.project_flock_kandang_id AS original_pfk
FROM product_warehouses pw10
WHERE pw10.warehouse_id = 10
AND pw10.id NOT IN (SELECT id FROM migration_audit.jamali_w10_pw_deleted_snapshot);
-- Snapshot stock_transfers yang akan di-soft-delete (self-loop W10<->W25)
-- Simpan from/to_warehouse_id asli supaya DOWN bisa reverse direction tepat
CREATE TABLE migration_audit.jamali_w10_st_softdeleted AS
SELECT id, movement_number, from_warehouse_id, to_warehouse_id
FROM stock_transfers
WHERE deleted_at IS NULL
AND ((from_warehouse_id = 10 AND to_warehouse_id = 25)
OR (from_warehouse_id = 25 AND to_warehouse_id = 10));
-- Snapshot stock_transfers yang akan di-UPDATE (W10<->other, bukan self-loop)
CREATE TABLE migration_audit.jamali_w10_st_redirected AS
SELECT id,
(from_warehouse_id = 10) AS was_from_w10,
(to_warehouse_id = 10) AS was_to_w10
FROM stock_transfers
WHERE deleted_at IS NULL
AND (from_warehouse_id = 10 OR to_warehouse_id = 10)
AND id NOT IN (SELECT id FROM migration_audit.jamali_w10_st_softdeleted);
-- Snapshot purchase_items IDs (cheap, ~17 rows)
CREATE TABLE migration_audit.jamali_w10_purchase_items AS
SELECT id FROM purchase_items WHERE warehouse_id = 10;
-- Snapshot warehouses soft-delete flag (1 row, kalau memang masih aktif)
CREATE TABLE migration_audit.jamali_w10_warehouse_softdeleted AS
SELECT id FROM warehouses WHERE id = 10 AND deleted_at IS NULL;
-- STEP 0: Pre-check sanity (idempotent guards)
DO $$
DECLARE v_count INT;
BEGIN
SELECT COUNT(*) INTO v_count FROM warehouses
WHERE id IN (10, 25) AND type = 'LOKASI' AND area_id = 6 AND location_id = 16;
IF v_count <> 2 THEN
RAISE EXCEPTION 'Pre-check: warehouse 10/25 schema mismatch (got % rows)', v_count;
END IF;
SELECT COUNT(*) INTO v_count FROM purchase_items a
JOIN purchase_items b ON a.purchase_id = b.purchase_id
AND a.product_id = b.product_id
AND a.id <> b.id
WHERE a.warehouse_id = 10 AND b.warehouse_id = 25;
IF v_count > 0 THEN
RAISE EXCEPTION 'Pre-check: % purchase_items unique conflict (purchase_id,product_id)', v_count;
END IF;
END $$;
-- STEP 1: Merge qty W10 -> W25 untuk overlap (pfk=NULL)
UPDATE product_warehouses pw25
SET qty = pw25.qty + pw10.qty
FROM product_warehouses pw10
WHERE pw10.warehouse_id = 10 AND pw10.project_flock_kandang_id IS NULL
AND pw25.warehouse_id = 25 AND pw25.project_flock_kandang_id IS NULL
AND pw25.product_id = pw10.product_id;
-- STEP 2: Build temp mapping (W10 PW id -> W25 PW id) untuk overlap saja
CREATE TEMP TABLE _pw_map ON COMMIT DROP AS
SELECT pw10.id AS old_id, pw25.id AS new_id
FROM product_warehouses pw10
JOIN product_warehouses pw25
ON pw25.product_id = pw10.product_id
AND pw25.warehouse_id = 25
AND pw25.project_flock_kandang_id IS NULL
WHERE pw10.warehouse_id = 10 AND pw10.project_flock_kandang_id IS NULL;
CREATE INDEX ON _pw_map(old_id);
-- STEP 3: Relink semua FK ke product_warehouses.id (hanya rows di _pw_map)
UPDATE stock_logs SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE stock_logs.product_warehouse_id = m.old_id;
UPDATE stock_allocations SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE stock_allocations.product_warehouse_id = m.old_id;
UPDATE recording_eggs SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE recording_eggs.product_warehouse_id = m.old_id;
UPDATE recording_stocks SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE recording_stocks.product_warehouse_id = m.old_id;
UPDATE recording_depletions SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE recording_depletions.product_warehouse_id = m.old_id;
UPDATE recording_depletions SET source_product_warehouse_id = m.new_id
FROM _pw_map m WHERE recording_depletions.source_product_warehouse_id = m.old_id;
UPDATE adjustment_stocks SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE adjustment_stocks.product_warehouse_id = m.old_id;
UPDATE marketing_products SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE marketing_products.product_warehouse_id = m.old_id;
UPDATE marketing_delivery_products SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE marketing_delivery_products.product_warehouse_id = m.old_id;
UPDATE project_chickins SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE project_chickins.product_warehouse_id = m.old_id;
UPDATE project_chickin_details SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE project_chickin_details.product_warehouse_id = m.old_id;
UPDATE project_flock_populations SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE project_flock_populations.product_warehouse_id = m.old_id;
UPDATE laying_transfers SET source_product_warehouse_id = m.new_id
FROM _pw_map m WHERE laying_transfers.source_product_warehouse_id = m.old_id;
UPDATE laying_transfer_sources SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE laying_transfer_sources.product_warehouse_id = m.old_id;
UPDATE laying_transfer_targets SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE laying_transfer_targets.product_warehouse_id = m.old_id;
UPDATE stock_transfer_details SET source_product_warehouse_id = m.new_id
FROM _pw_map m WHERE stock_transfer_details.source_product_warehouse_id = m.old_id;
UPDATE stock_transfer_details SET dest_product_warehouse_id = m.new_id
FROM _pw_map m WHERE stock_transfer_details.dest_product_warehouse_id = m.old_id;
UPDATE purchase_items SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE purchase_items.product_warehouse_id = m.old_id;
-- FIFO v2 tables (kosong di dump 2026-05-25, defensive)
UPDATE fifo_stock_v2_operation_log SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE fifo_stock_v2_operation_log.product_warehouse_id = m.old_id;
UPDATE fifo_stock_v2_reflow_checkpoints SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE fifo_stock_v2_reflow_checkpoints.product_warehouse_id = m.old_id;
UPDATE fifo_stock_v2_shadow_allocations SET product_warehouse_id = m.new_id
FROM _pw_map m WHERE fifo_stock_v2_shadow_allocations.product_warehouse_id = m.old_id;
-- STEP 4: Hard-delete W10 PW yang sudah merged (9 rows expected)
DELETE FROM product_warehouses WHERE id IN (SELECT old_id FROM _pw_map);
-- STEP 5: Sisa W10 PW (4 rows: 1188/1189/1190/1196) -> warehouse_id=25,
-- pfk dinormalisasi ke NULL sekalian (LOKASI rule)
UPDATE product_warehouses
SET warehouse_id = 25, project_flock_kandang_id = NULL
WHERE warehouse_id = 10;
-- STEP 6: purchase_items.warehouse_id (17 rows)
UPDATE purchase_items SET warehouse_id = 25 WHERE warehouse_id = 10;
-- STEP 7: stock_transfers
-- 7.1 Soft-delete self-loop (W10<->W25 akan jadi W25<->W25)
UPDATE stock_transfers
SET deleted_at = NOW(), updated_at = NOW()
WHERE deleted_at IS NULL
AND ((from_warehouse_id = 10 AND to_warehouse_id = 25)
OR (from_warehouse_id = 25 AND to_warehouse_id = 10));
-- 7.2 Sisa W10<->other -> 25 (12 EGG_FARM_CUTOVER ke W10)
UPDATE stock_transfers SET from_warehouse_id = 25, updated_at = NOW() WHERE from_warehouse_id = 10;
UPDATE stock_transfers SET to_warehouse_id = 25, updated_at = NOW() WHERE to_warehouse_id = 10;
-- STEP 8: Soft-delete warehouse 10 sendiri
UPDATE warehouses SET deleted_at = NOW(), updated_at = NOW()
WHERE id = 10 AND deleted_at IS NULL;
-- STEP 9: Post-check (fail-fast jika ada residu)
DO $$
DECLARE v_count INT;
BEGIN
SELECT COUNT(*) INTO v_count FROM product_warehouses WHERE warehouse_id = 10;
IF v_count <> 0 THEN RAISE EXCEPTION 'product_warehouses W10 residual %', v_count; END IF;
SELECT COUNT(*) INTO v_count FROM purchase_items WHERE warehouse_id = 10;
IF v_count <> 0 THEN RAISE EXCEPTION 'purchase_items W10 residual %', v_count; END IF;
SELECT COUNT(*) INTO v_count FROM stock_transfers
WHERE deleted_at IS NULL AND (from_warehouse_id = 10 OR to_warehouse_id = 10);
IF v_count <> 0 THEN RAISE EXCEPTION 'stock_transfers W10 residual %', v_count; END IF;
END $$;
COMMIT;
@@ -0,0 +1,29 @@
BEGIN;
-- ============================================================
-- Rollback stock_log drift fix: DELETE corrective rows yang di-insert UP.
-- IDs ditarik dari audit table `migration_audit.jamali_w10_stocklog_corrections`.
-- Setelah delete, `last_stock_log.stock` kembali ke nilai pre-fix (drift muncul lagi).
-- ============================================================
-- Guard: audit table harus ada
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'migration_audit'
AND table_name = 'jamali_w10_stocklog_corrections'
) THEN
RAISE EXCEPTION
'Audit table migration_audit.jamali_w10_stocklog_corrections tidak ditemukan. UP belum dijalankan atau audit sudah di-drop.';
END IF;
END $$;
-- DELETE corrective stock_logs yang di-insert oleh UP
DELETE FROM stock_logs
WHERE id IN (SELECT stock_log_id FROM migration_audit.jamali_w10_stocklog_corrections);
-- Cleanup audit table
DROP TABLE migration_audit.jamali_w10_stocklog_corrections;
COMMIT;
@@ -0,0 +1,111 @@
BEGIN;
-- ============================================================
-- Fix stock_log drift pasca-merge warehouse Jamali (NON_AKTIF) -> Gudang Farm Jamali.
-- Follow-up migration setelah 20260528121631_normalize_warehouse_jamali_10_to_25.
--
-- Setelah merge, `stock_logs.stock` (running ledger) drift dari
-- `product_warehouses.qty` karena: pre-existing drift di W10 + W25 sources,
-- plus FIFO reflow yang trigger pasca-merge (Recording-Edit) recompute
-- pw.qty tapi stock_logs tidak ikut update.
--
-- Migration ini insert 1 ADJUSTMENT stock_log corrective per PW yang drift
-- supaya `last_stock_log.stock = pw.qty`. Logic ekivalen dengan
-- `cmd/fix-stock-log-drift`.
--
-- Karakteristik dynamic:
-- - Tidak hardcode PW IDs atau drift values
-- - Iterate via merge target + W10-only kept PWs (data-driven dari snapshot)
-- - Per PW: hitung drift runtime, skip kalau negligible (< 0.001) atau no logs
-- - Track stock_log IDs yang di-insert untuk DOWN reverse
-- ============================================================
-- Guard: previous migration (normalisasi) audit harus ada
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'migration_audit'
AND table_name = 'jamali_w10_qty_merge'
) THEN
RAISE EXCEPTION
'Migration 20260528121631 (normalize_warehouse_jamali) belum dijalankan atau audit-nya sudah di-drop. Apply UP-nya dulu sebelum migration ini.';
END IF;
END $$;
-- Audit table untuk track stock_log IDs yang di-insert (untuk DOWN reverse)
DROP TABLE IF EXISTS migration_audit.jamali_w10_stocklog_corrections;
CREATE TABLE migration_audit.jamali_w10_stocklog_corrections (
stock_log_id BIGINT NOT NULL PRIMARY KEY,
product_warehouse_id BIGINT NOT NULL,
drift NUMERIC(15,3) NOT NULL,
inserted_at TIMESTAMPTZ DEFAULT NOW()
);
-- Insert corrective ADJUSTMENT stock_log untuk tiap PW yang drift
DO $$
DECLARE
rec RECORD;
v_last_log_stock NUMERIC(15,3);
v_drift NUMERIC(15,3);
v_new_log_id BIGINT;
v_inserts INT := 0;
BEGIN
FOR rec IN (
SELECT pw.id AS pw_id, pw.qty AS qty
FROM product_warehouses pw
WHERE pw.id IN (
-- Merge target W25 PWs (9 rows)
SELECT target_pw_id FROM migration_audit.jamali_w10_qty_merge
UNION
-- W10-only PWs yang di-update warehouse_id 10->25 (4 rows)
SELECT id FROM migration_audit.jamali_w10_pw_w10only_snapshot
)
) LOOP
-- Ambil stock akhir di stock_logs ledger
SELECT stock INTO v_last_log_stock
FROM stock_logs
WHERE product_warehouse_id = rec.pw_id
ORDER BY id DESC
LIMIT 1;
-- PW tanpa stock_logs entry (mis. 1188/1189/1190 ayam) -> skip
IF v_last_log_stock IS NULL THEN
CONTINUE;
END IF;
v_drift := rec.qty - v_last_log_stock;
-- Drift negligible -> skip
IF ABS(v_drift) < 0.001 THEN
CONTINUE;
END IF;
-- Insert corrective ADJUSTMENT stock_log
INSERT INTO stock_logs (
product_warehouse_id, loggable_type, loggable_id,
notes, increase, decrease, stock, created_by, created_at
) VALUES (
rec.pw_id,
'ADJUSTMENT',
0,
'Koreksi stock_log drift pasca-merge warehouse Jamali (migration 20260528123243)',
CASE WHEN v_drift > 0 THEN v_drift ELSE 0 END,
CASE WHEN v_drift < 0 THEN -v_drift ELSE 0 END,
rec.qty,
1,
NOW()
) RETURNING id INTO v_new_log_id;
-- Track ke audit table untuk DOWN
INSERT INTO migration_audit.jamali_w10_stocklog_corrections (
stock_log_id, product_warehouse_id, drift
) VALUES (v_new_log_id, rec.pw_id, v_drift);
v_inserts := v_inserts + 1;
END LOOP;
RAISE NOTICE 'Inserted % corrective stock_logs to align ledger with pw.qty', v_inserts;
END $$;
COMMIT;
+1
View File
@@ -15,6 +15,7 @@ type Customer struct {
Phone string `gorm:"not null;size:20"`
Email string `gorm:"type:varchar(50);not null"`
AccountNumber string `gorm:"not null;size:50"`
BankName string `gorm:"not null;size:100;default:''"`
Balance float64 `gorm:"default:0"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
@@ -0,0 +1,27 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type DailyChecklistEmptyKandang struct {
Id uint `gorm:"primaryKey"`
DailyChecklistId uint `gorm:"not null"`
KandangId uint `gorm:"not null"`
StartDate time.Time `gorm:"type:date;not null"`
EndDate time.Time `gorm:"type:date;not null"`
CreatedBy *uint
DeletedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
DailyChecklist *DailyChecklist `gorm:"foreignKey:DailyChecklistId;references:Id"`
Kandang *KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
}
func (DailyChecklistEmptyKandang) TableName() string {
return "daily_checklist_empty_kandangs"
}
+1
View File
@@ -28,6 +28,7 @@ type DailyChecklist struct {
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
EmptyKandang *DailyChecklistEmptyKandang `gorm:"foreignKey:DailyChecklistId;references:Id"`
}
type DailyChecklistPhase struct {
+1
View File
@@ -17,6 +17,7 @@ type Expense struct {
RealizationDate time.Time `gorm:"type:date;column:realization_date"`
TransactionDate time.Time `gorm:"type:date;not null"`
Notes string `gorm:"type:text;column:notes"`
IsPaid bool `gorm:"column:is_paid;not null;default:false"`
CreatedBy uint64 `gorm:""`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
+1
View File
@@ -43,6 +43,7 @@ type Recording struct {
StandardEggMass *float64 `gorm:"-"`
StandardEggWeight *float64 `gorm:"-"`
StandardFcr *float64 `gorm:"-"`
StandardWeek *int `gorm:"-"`
PopulationCanChange *bool `gorm:"-"`
TransferExecuted *bool `gorm:"-"`
IsTransition *bool `gorm:"-"`
+1
View File
@@ -19,6 +19,7 @@ type Supplier struct {
Address string `gorm:"not null"`
Npwp *string `gorm:"size:50"`
AccountNumber *string `gorm:"size:50"`
BankName *string `gorm:"size:100"`
Balance float64 `gorm:"type:numeric(15,3);default:0"`
DueDate int `gorm:"not null"`
CreatedBy uint `gorm:"not null"`
+2
View File
@@ -66,6 +66,7 @@ const (
P_ProductStockGetOne = "lti.inventory.product_stock.detail"
P_ProductWarehousekGetAll = "lti.inventory.product_warehouses.list"
P_ProductWarehouseGetOne = "lti.inventory.product_warehouses.detail"
P_StockLogGetAll = "lti.inventory.stock_log.list"
)
const (
P_ClosingGetAll = "lti.closing.list"
@@ -207,6 +208,7 @@ const (
P_Finances_Transaction_DeleteOne = "lti.finance.transactions.delete"
)
const (
P_ChickinsGetAll = "lti.production.chickins.list"
P_ChickinsCreateOne = "lti.production.chickins.create"
P_ChickinsGetOne = "lti.production.chickins.detail"
P_ChickinsApproval = "lti.production.chickins.approve"
@@ -412,6 +412,33 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
})
}
func (u *DailyChecklistController) UpdateByPut(c *fiber.Ctx) error {
req := new(validation.Create)
param := c.Params("idDailyChecklist")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.DailyChecklistService.UpdateByPut(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update dailyChecklist successfully",
Data: dto.ToDailyChecklistListDTO(*result),
})
}
func validateDailyChecklistDocumentSizes(files []*multipart.FileHeader) error {
const maxDailyChecklistDocumentBytes = 5 * 1024 * 1024 // 5MB
for _, file := range files {
@@ -42,6 +42,13 @@ type DailyChecklistDetailDTO struct {
TotalActivity int `json:"total_activity"`
Progress float64 `json:"progress"`
DocumentURLs []DailyChecklistDocumentDTO `json:"document_urls"`
EmptyKandang *DailyChecklistEmptyKandangDTO `json:"empty_kandang,omitempty"`
}
type DailyChecklistEmptyKandangDTO struct {
Id uint `json:"id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
}
type DailyChecklistDocumentDTO struct {
@@ -180,6 +187,17 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO {
}
}
func ToDailyChecklistEmptyKandangDTO(e *entity.DailyChecklistEmptyKandang) *DailyChecklistEmptyKandangDTO {
if e == nil || e.Id == 0 {
return nil
}
return &DailyChecklistEmptyKandangDTO{
Id: e.Id,
StartDate: e.StartDate,
EndDate: e.EndDate,
}
}
func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64, documentURLs []DailyChecklistDocumentDTO) DailyChecklistDetailDTO {
phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases))
for _, phase := range phases {
@@ -241,5 +259,6 @@ func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.
TotalActivity: totalActivities,
Progress: progress,
DocumentURLs: documentURLs,
EmptyKandang: ToDailyChecklistEmptyKandangDTO(checklist.EmptyKandang),
}
}
+2 -1
View File
@@ -22,6 +22,7 @@ type DailyChecklistModule struct{}
func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db)
emptyKandangRepo := rDailyChecklist.NewDailyChecklistEmptyKandangRepository(db)
phasesRepo := rPhases.NewPhasesRepository(db)
userRepo := rUser.NewUserRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
@@ -30,7 +31,7 @@ func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
panic(fmt.Sprintf("failed to create document service: %v", err))
}
dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate, documentSvc)
dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, emptyKandangRepo, phasesRepo, validate, documentSvc)
userService := sUser.NewUserService(userRepo, validate)
DailyChecklistRoutes(router, userService, dailyChecklistService)
@@ -0,0 +1,98 @@
package repository
import (
"context"
"errors"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type DailyChecklistEmptyKandangRepository interface {
repository.BaseRepository[entity.DailyChecklistEmptyKandang]
FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error)
FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error)
FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error)
FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error)
SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error
}
type DailyChecklistEmptyKandangRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.DailyChecklistEmptyKandang]
}
func NewDailyChecklistEmptyKandangRepository(db *gorm.DB) DailyChecklistEmptyKandangRepository {
return &DailyChecklistEmptyKandangRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklistEmptyKandang](db),
}
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("daily_checklist_id = ?", dailyChecklistID).
First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
query := r.DB().WithContext(ctx).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate)
if excludeDailyChecklistID > 0 {
query = query.Where("daily_checklist_id <> ?", excludeDailyChecklistID)
}
if err := query.First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date).
First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error) {
if len(kandangIDs) == 0 {
return []entity.DailyChecklistEmptyKandang{}, nil
}
var recs []entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?", kandangIDs, rangeEnd, rangeStart).
Find(&recs).Error; err != nil {
return nil, err
}
return recs, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error {
updates := map[string]any{
"deleted_at": time.Now(),
}
if actorID != nil {
updates["deleted_by"] = *actorID
}
return r.DB().WithContext(ctx).
Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", dailyChecklistID).
Updates(updates).Error
}
@@ -59,6 +59,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist.
route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment)
route.Patch("/bulk-update", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.BulkUpdate)
route.Put("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateByPut)
route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne)
route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne)
}
@@ -29,6 +29,7 @@ type DailyChecklistService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error)
UpdateByPut(ctx *fiber.Ctx, req *validation.Create, id uint) (*entity.DailyChecklist, error)
BulkUpdate(ctx *fiber.Ctx, req *validation.BulkStatusUpdate) ([]entity.DailyChecklist, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error
@@ -46,6 +47,7 @@ type dailyChecklistService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.DailyChecklistRepository
EmptyKandangRepo repository.DailyChecklistEmptyKandangRepository
PhaseRepo phaseRepo.PhasesRepository
DocumentSvc commonSvc.DocumentService
}
@@ -127,23 +129,26 @@ const (
dailyChecklistCategoryEmptyKandang = "empty_kandang"
dailyChecklistStatusRejected = "REJECTED"
dailyChecklistStatusDraft = "DRAFT"
dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range"
dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist"
dailyChecklistErrDeletedNonEmptyKandangExists = "DailyChecklist cannot be created as empty_kandang because a deleted non-empty_kandang checklist exists for this date"
dailyChecklistErrEmptyKandangRangeOverlap = "Empty kandang range overlaps with an existing empty kandang period for this kandang"
dailyChecklistErrDateInsideEmptyKandang = "Tanggal berada dalam periode kandang kosong untuk kandang ini"
dailyChecklistErrEmptyKandangEndDateInvalid = "empty_kandang_end_date harus >= date"
)
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
func NewDailyChecklistService(repo repository.DailyChecklistRepository, emptyKandangRepo repository.DailyChecklistEmptyKandangRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
return &dailyChecklistService{
Log: utils.Log,
Validate: validate,
Repository: repo,
EmptyKandangRepo: emptyKandangRepo,
PhaseRepo: phaseRepo,
DocumentSvc: documentSvc,
}
}
func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("Kandang")
return db.Preload("Kandang").Preload("EmptyKandang")
}
func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error {
@@ -277,7 +282,11 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
normalizedSearch := re.ReplaceAllString(params.Search, "")
if normalizedSearch != "" {
like := "%" + normalizedSearch + "%"
db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", like, like)
db = db.Where(`(
regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR
regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR
(dc.category = 'empty_kandang' AND regexp_replace('Kandang Kosong', '[^a-zA-Z0-9]', '', 'g') ILIKE ?)
)`, like, like, like)
}
}
@@ -525,6 +534,23 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
category = dailyChecklistCategoryEmptyKandang
}
var emptyEndDate time.Time
if category == dailyChecklistCategoryEmptyKandang {
trimmedEnd := strings.TrimSpace(req.EmptyKandangEndDate)
if trimmedEnd == "" {
emptyEndDate = date
} else {
parsedEnd, parseErr := time.Parse(dailyChecklistDateLayout, trimmedEnd)
if parseErr != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
}
if parsedEnd.Before(date) {
return nil, fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrEmptyKandangEndDateInvalid)
}
emptyEndDate = parsedEnd
}
}
targetID := uint(0)
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
@@ -533,19 +559,39 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
}
if category == dailyChecklistCategoryEmptyKandang {
if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, date); err != nil {
if err := s.validateNoNormalChecklistInRange(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err
}
if err := s.validateNoEmptyKandangRangeOverlap(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err
}
if err := s.validateNoExistingEmptyKandangInRange(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err
}
if err := s.validateNoDeletedNonEmptyKandangForDate(tx, req.KandangId, date); err != nil {
return err
}
} else {
if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, date); err != nil {
if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, 0); err != nil {
return err
}
if err := s.validateDateNotInExistingEmptyKandangChecklist(tx, req.KandangId, date, 0); err != nil {
return err
}
}
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
if err := s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID); err != nil {
return err
}
if category == dailyChecklistCategoryEmptyKandang {
actorID, _ := m.ActorIDFromContext(c)
if err := s.upsertEmptyKandangRange(tx, targetID, req.KandangId, date, emptyEndDate, actorID); err != nil {
return err
}
}
return nil
})
if err != nil {
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
@@ -575,34 +621,118 @@ func (s *dailyChecklistService) lockKandangForChecklistCreation(tx *gorm.DB, kan
return nil
}
func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error {
func (s *dailyChecklistService) validateNoNormalChecklistInRange(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category <> ? AND deleted_at IS NULL",
kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", kandangID, startDate, endDate).
Count(&conflictCount).Error; err != nil {
if err := q.Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist)
}
return nil
}
func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error {
func (s *dailyChecklistService) validateNoEmptyKandangRangeOverlap(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate)
if excludeDCID > 0 {
q = q.Where("daily_checklist_id <> ?", excludeDCID)
}
var overlapCount int64
if err := q.Count(&overlapCount).Error; err != nil {
return err
}
if overlapCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap)
}
return nil
}
func (s *dailyChecklistService) validateDateNotInEmptyKandangRange(tx *gorm.DB, kandangID uint, date time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date)
if excludeDCID > 0 {
q = q.Where("daily_checklist_id <> ?", excludeDCID)
}
var rec entity.DailyChecklistEmptyKandang
if err := q.First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang)
}
func (s *dailyChecklistService) validateNoExistingEmptyKandangInRange(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL",
kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang).
Count(&conflictCount).Error; err != nil {
if err := q.Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap)
}
return nil
}
func (s *dailyChecklistService) validateDateNotInExistingEmptyKandangChecklist(tx *gorm.DB, kandangID uint, date time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL",
kandangID, date, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64
if err := q.Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang)
}
return nil
}
func (s *dailyChecklistService) upsertEmptyKandangRange(tx *gorm.DB, dailyChecklistID, kandangID uint, startDate, endDate time.Time, actorID uint) error {
var existing entity.DailyChecklistEmptyKandang
err := tx.Where("daily_checklist_id = ?", dailyChecklistID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangExist)
if err == nil {
return tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("id = ?", existing.Id).
Updates(map[string]any{
"kandang_id": kandangID,
"start_date": startDate,
"end_date": endDate,
"updated_at": time.Now(),
}).Error
}
return nil
record := &entity.DailyChecklistEmptyKandang{
DailyChecklistId: dailyChecklistID,
KandangId: kandangID,
StartDate: startDate,
EndDate: endDate,
}
if actorID > 0 {
actor := actorID
record.CreatedBy = &actor
}
return tx.Create(record).Error
}
func (s *dailyChecklistService) validateNoDeletedNonEmptyKandangForDate(tx *gorm.DB, kandangID uint, date time.Time) error {
@@ -862,6 +992,157 @@ func (s dailyChecklistService) BulkUpdate(c *fiber.Ctx, req *validation.BulkStat
return updated, nil
}
func (s *dailyChecklistService) UpdateByPut(c *fiber.Ctx, req *validation.Create, id uint) (*entity.DailyChecklist, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if err := s.ensureChecklistAccess(c, id); err != nil {
return nil, err
}
date, err := time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.Date))
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD")
}
category := req.Category
if req.EmptyKandang {
category = dailyChecklistCategoryEmptyKandang
}
status := req.Status
var emptyEndDate time.Time
if category == dailyChecklistCategoryEmptyKandang {
trimmedEnd := strings.TrimSpace(req.EmptyKandangEndDate)
if trimmedEnd == "" {
emptyEndDate = date
} else {
parsedEnd, parseErr := time.Parse(dailyChecklistDateLayout, trimmedEnd)
if parseErr != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
}
if parsedEnd.Before(date) {
return nil, fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrEmptyKandangEndDateInvalid)
}
emptyEndDate = parsedEnd
}
}
var wasBranchC bool // non-empty_kandang → empty_kandang transition
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil {
return err
}
var existing entity.DailyChecklist
if err := tx.Where("id = ? AND deleted_at IS NULL", id).First(&existing).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return err
}
existingIsEmpty := existing.Category == dailyChecklistCategoryEmptyKandang
newIsEmpty := category == dailyChecklistCategoryEmptyKandang
if newIsEmpty {
if err := s.validateNoNormalChecklistInRange(tx, req.KandangId, date, emptyEndDate, id); err != nil {
return err
}
if err := s.validateNoEmptyKandangRangeOverlap(tx, req.KandangId, date, emptyEndDate, id); err != nil {
return err
}
} else {
if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, id); err != nil {
return err
}
}
var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("id <> ? AND date = ? AND kandang_id = ? AND category = ? AND deleted_at IS NULL",
id, date, req.KandangId, category).
Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, "DailyChecklist already exists with same date, kandang, and category")
}
result := tx.Model(&entity.DailyChecklist{}).Where("id = ?", id).Updates(map[string]any{
"date": date,
"kandang_id": req.KandangId,
"category": category,
"status": status,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
actorID, _ := m.ActorIDFromContext(c)
if newIsEmpty {
if err := s.upsertEmptyKandangRange(tx, id, req.KandangId, date, emptyEndDate, actorID); err != nil {
return err
}
// Branch C: non-empty → empty_kandang, hard-delete task/progress data
if !existingIsEmpty {
wasBranchC = true
if err := tx.Exec(`
DELETE FROM daily_checklist_activity_task_assignments
WHERE task_id IN (
SELECT id FROM daily_checklist_activity_tasks WHERE checklist_id = ?
)`, id).Error; err != nil {
return err
}
if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistActivityTask{}).Error; err != nil {
return err
}
if err := tx.Where("daily_checklist_id = ?", id).Delete(&entity.DailyChecklistTask{}).Error; err != nil {
return err
}
}
} else if existingIsEmpty {
updates := map[string]any{
"deleted_at": time.Now(),
}
if actorID > 0 {
updates["deleted_by"] = actorID
}
if err := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", id).
Updates(updates).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
// Branch C: delete DC documents outside transaction (storage is external)
if wasBranchC && s.DocumentSvc != nil {
docs, docErr := s.DocumentSvc.ListByTarget(c.Context(), string(utils.DocumentTypeDailyChecklist), uint64(id))
if docErr == nil && len(docs) > 0 {
docIDs := make([]uint, 0, len(docs))
for _, doc := range docs {
docIDs = append(docIDs, doc.Id)
}
if delErr := s.DocumentSvc.DeleteDocuments(c.Context(), docIDs, true); delErr != nil {
s.Log.Errorf("Failed to delete documents for DC %d during empty_kandang conversion: %+v", id, delErr)
}
}
}
return s.GetOne(c, id)
}
func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
@@ -893,6 +1174,15 @@ func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
return gorm.ErrRecordNotFound
}
if err := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", id).
Updates(map[string]any{
"deleted_at": time.Now(),
"deleted_by": actorID,
}).Error; err != nil {
return err
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -1455,11 +1745,12 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
Group("a.id, a.name, loc.id, loc.name, k.id, k.name, e.id, e.name, p.id, p.name")
}
var total int64
// --- Count approved rows ---
var approvedTotal int64
groupedForCount := buildGroupedQuery()
if err := s.Repository.DB().WithContext(c.Context()).
Table("(?) AS grouped", groupedForCount).
Count(&total).Error; err != nil {
Count(&approvedTotal).Error; err != nil {
s.Log.Errorf("Failed to count report data: %+v", err)
return nil, 0, err
}
@@ -1479,19 +1770,197 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
TotalAssignments int64
}
rows := make([]reportRow, 0)
if err := buildGroupedQuery().
Order("a.name, loc.name, k.name, e.name").
Offset(offset).
Limit(params.Limit).
Scan(&rows).Error; err != nil {
s.Log.Errorf("Failed to fetch report data: %+v", err)
type fallbackRowType struct {
AreaID uint
AreaName string
LocationID uint
LocationName string
KandangID uint
KandangName string
EmployeeID uint
EmployeeName string
}
// buildFallbackQ returns employees in kandangs that have NO approved checklist data
// for the filtered period. Applies the same scope/area/location/kandang/employee filters.
buildFallbackQ := func() *gorm.DB {
approvedKandangSubQ := buildBase().Select("DISTINCT dc.kandang_id")
q := s.Repository.DB().WithContext(c.Context()).
Table("employee_kandangs ek").
Joins("JOIN employees e ON e.id = ek.employee_id AND e.deleted_at IS NULL").
Joins("JOIN kandang_groups k ON k.id = ek.kandang_id AND k.deleted_at IS NULL").
Joins("JOIN locations loc ON loc.id = k.location_id AND loc.deleted_at IS NULL").
Joins("JOIN areas a ON a.id = loc.area_id AND a.deleted_at IS NULL").
Where("ek.kandang_id NOT IN (?)", approvedKandangSubQ).
Select("e.id AS employee_id, e.name AS employee_name, k.id AS kandang_id, k.name AS kandang_name, loc.id AS location_id, loc.name AS location_name, a.id AS area_id, a.name AS area_name")
q = m.ApplyScopeFilter(q, locationScope, "loc.id")
q = m.ApplyScopeFilter(q, areaScope, "a.id")
if params.AreaID != nil {
q = q.Where("a.id = ?", *params.AreaID)
}
if params.LocationID != nil {
q = q.Where("loc.id = ?", *params.LocationID)
}
if params.KandangID != nil {
q = q.Where("ek.kandang_id = ?", *params.KandangID)
}
if params.EmployeeID != nil {
q = q.Where("ek.employee_id = ?", *params.EmployeeID)
}
// PhaseID not applied: fallback rows have no phase data
return q
}
// --- Count fallback rows ---
var fallbackTotal int64
if err := s.Repository.DB().WithContext(c.Context()).
Table("(?) AS fb", buildFallbackQ()).
Count(&fallbackTotal).Error; err != nil {
s.Log.Errorf("Failed to count fallback report data: %+v", err)
return nil, 0, err
}
if len(rows) == 0 {
total := approvedTotal + fallbackTotal
// --- Fetch ALL approved rows (pagination done in Go after merging with fallback) ---
allApprovedRows := make([]reportRow, 0)
if approvedTotal > 0 {
if err := buildGroupedQuery().
Order("a.name, loc.name, k.name, e.name").
Scan(&allApprovedRows).Error; err != nil {
s.Log.Errorf("Failed to fetch report data: %+v", err)
return nil, 0, err
}
}
// --- Fetch ALL fallback rows ---
allFallbackRows := make([]fallbackRowType, 0)
if fallbackTotal > 0 {
if err := buildFallbackQ().
Order("a.name, loc.name, k.name, e.name").
Scan(&allFallbackRows).Error; err != nil {
s.Log.Errorf("Failed to fetch fallback report data: %+v", err)
return nil, 0, err
}
}
// --- Merge approved + fallback and sort consistently ---
type mergedEntry struct {
AreaName string
LocationName string
KandangName string
EmployeeName string
IsApproved bool
Idx int
}
merged := make([]mergedEntry, 0, len(allApprovedRows)+len(allFallbackRows))
for i, r := range allApprovedRows {
merged = append(merged, mergedEntry{
AreaName: r.AreaName, LocationName: r.LocationName,
KandangName: r.KandangName, EmployeeName: r.EmployeeName,
IsApproved: true, Idx: i,
})
}
for i, r := range allFallbackRows {
merged = append(merged, mergedEntry{
AreaName: r.AreaName, LocationName: r.LocationName,
KandangName: r.KandangName, EmployeeName: r.EmployeeName,
IsApproved: false, Idx: i,
})
}
sort.Slice(merged, func(i, j int) bool {
a, b := merged[i], merged[j]
if a.AreaName != b.AreaName {
return a.AreaName < b.AreaName
}
if a.LocationName != b.LocationName {
return a.LocationName < b.LocationName
}
if a.KandangName != b.KandangName {
return a.KandangName < b.KandangName
}
return a.EmployeeName < b.EmployeeName
})
// --- Apply Go-level pagination ---
end := offset + params.Limit
if end > len(merged) {
end = len(merged)
}
if offset >= len(merged) {
return []DailyChecklistReportItem{}, total, nil
}
pageData := merged[offset:end]
// --- Split page into approved vs fallback rows ---
pageApproved := make([]reportRow, 0)
pageFallback := make([]fallbackRowType, 0)
for _, entry := range pageData {
if entry.IsApproved {
pageApproved = append(pageApproved, allApprovedRows[entry.Idx])
} else {
pageFallback = append(pageFallback, allFallbackRows[entry.Idx])
}
}
applyEmptyKandangFlags := func(items []DailyChecklistReportItem, kandangIDs []uint) error {
if len(kandangIDs) == 0 {
return nil
}
firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC)
lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1)
type emptyRangeRec struct {
KandangID uint
StartDate time.Time
EndDate time.Time
}
var rangeRecs []emptyRangeRec
if err := s.Repository.DB().WithContext(c.Context()).
Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?",
kandangIDs, lastDay, firstDay).
Select("kandang_id, start_date, end_date").
Scan(&rangeRecs).Error; err != nil {
s.Log.Errorf("Failed to get empty kandang ranges for report: %+v", err)
return err
}
emptyDaysByKandang := make(map[uint]map[int]struct{})
for _, rec := range rangeRecs {
effectiveStart := rec.StartDate
if effectiveStart.Before(firstDay) {
effectiveStart = firstDay
}
effectiveEnd := rec.EndDate
if effectiveEnd.After(lastDay) {
effectiveEnd = lastDay
}
if effectiveStart.After(effectiveEnd) {
continue
}
if _, ok := emptyDaysByKandang[rec.KandangID]; !ok {
emptyDaysByKandang[rec.KandangID] = make(map[int]struct{})
}
for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) {
emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{}
}
}
for i, item := range items {
daySet := emptyDaysByKandang[item.KandangID]
for day := range daySet {
key := strconv.Itoa(day)
if _, exists := items[i].DailyActivities[key]; !exists {
items[i].DailyActivities[key] = "Kandang kosong"
}
}
}
return nil
}
type comboKey struct {
EmployeeID uint
@@ -1513,7 +1982,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
kandangSet := make(map[uint]struct{})
phaseSet := make(map[uint]struct{})
for _, row := range rows {
for _, row := range pageApproved {
key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID}
comboSet[key] = struct{}{}
if _, ok := employeeSet[row.EmployeeID]; !ok {
@@ -1654,8 +2123,9 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
return selected
}
items := make([]DailyChecklistReportItem, len(rows))
for i, row := range rows {
// --- Build approved items (existing logic) ---
approvedItems := make([]DailyChecklistReportItem, len(pageApproved))
for i, row := range pageApproved {
key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID}
activities := dailyActivityMap[key]
@@ -1702,7 +2172,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
kandangPercentage = int(math.Round(float64(kandangStat.Completed) / float64(kandangStat.Total) * 100))
}
items[i] = DailyChecklistReportItem{
approvedItems[i] = DailyChecklistReportItem{
AreaID: row.AreaID,
AreaName: row.AreaName,
LocationID: row.LocationID,
@@ -1723,109 +2193,55 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
}
}
// Flag empty kandang days within this report month
if len(kandangIDs) > 0 {
firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC)
lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1)
today := time.Now().UTC().Truncate(24 * time.Hour)
type emptyKandangRec struct {
KandangID uint
Date time.Time
// --- Build fallback items (kandangs with no approved data) ---
fallbackItems := make([]DailyChecklistReportItem, len(pageFallback))
for i, fb := range pageFallback {
fallbackItems[i] = DailyChecklistReportItem{
AreaID: fb.AreaID,
AreaName: fb.AreaName,
LocationID: fb.LocationID,
LocationName: fb.LocationName,
KandangID: fb.KandangID,
KandangName: fb.KandangName,
EmployeeID: fb.EmployeeID,
EmployeeName: fb.EmployeeName,
PhaseName: "",
DailyActivities: map[string]any{},
Summary: DailyChecklistReportSummary{},
}
var emptyRecs []emptyKandangRec
if err := s.Repository.DB().WithContext(c.Context()).
Model(&entity.DailyChecklist{}).
Where("kandang_id IN ? AND category = ? AND date <= ? AND deleted_at IS NULL",
kandangIDs, dailyChecklistCategoryEmptyKandang, lastDay).
Select("kandang_id, date").
Scan(&emptyRecs).Error; err != nil {
s.Log.Errorf("Failed to get empty kandang records for report: %+v", err)
}
// --- Reconstruct allItems in the sorted pageData order ---
allItems := make([]DailyChecklistReportItem, len(pageData))
approvedIdx := 0
fallbackIdx := 0
for i, entry := range pageData {
if entry.IsApproved {
allItems[i] = approvedItems[approvedIdx]
approvedIdx++
} else {
allItems[i] = fallbackItems[fallbackIdx]
fallbackIdx++
}
}
// --- Collect all kandangIDs on this page (approved + fallback) for empty_kandang flags ---
allKandangSet := make(map[uint]struct{})
for _, id := range kandangIDs {
allKandangSet[id] = struct{}{}
}
for _, fb := range pageFallback {
allKandangSet[fb.KandangID] = struct{}{}
}
allKandangIDs := make([]uint, 0, len(allKandangSet))
for id := range allKandangSet {
allKandangIDs = append(allKandangIDs, id)
}
// --- Flag empty kandang days within this report month ---
if err := applyEmptyKandangFlags(allItems, allKandangIDs); err != nil {
return nil, 0, err
}
emptyDaysByKandang := make(map[uint]map[int]struct{})
if len(emptyRecs) > 0 {
minEmptyDate := emptyRecs[0].Date
for _, rec := range emptyRecs[1:] {
if rec.Date.Before(minEmptyDate) {
minEmptyDate = rec.Date
}
}
type checklistDateRec struct {
KandangID uint
Date time.Time
}
var nextDates []checklistDateRec
if err := s.Repository.DB().WithContext(c.Context()).
Model(&entity.DailyChecklist{}).
Where("kandang_id IN ? AND category != ? AND date > ? AND (status IS NULL OR status != ?) AND deleted_at IS NULL",
kandangIDs, dailyChecklistCategoryEmptyKandang, minEmptyDate, dailyChecklistStatusRejected).
Select("kandang_id, date").
Order("kandang_id ASC, date ASC").
Scan(&nextDates).Error; err != nil {
s.Log.Errorf("Failed to get next checklist dates for empty kandang: %+v", err)
return nil, 0, err
}
nextDatesByKandang := make(map[uint][]time.Time)
for _, row := range nextDates {
nextDatesByKandang[row.KandangID] = append(nextDatesByKandang[row.KandangID], row.Date)
}
for _, rec := range emptyRecs {
var nextDate time.Time
for _, d := range nextDatesByKandang[rec.KandangID] {
if d.After(rec.Date) {
nextDate = d
break
}
}
// If no next checklist, cap empty period at today (not end of month)
ceiling := lastDay
if today.Before(lastDay) {
ceiling = today
}
periodEnd := ceiling
if !nextDate.IsZero() {
periodEnd = nextDate.AddDate(0, 0, -1)
}
effectiveStart := rec.Date
if effectiveStart.Before(firstDay) {
effectiveStart = firstDay
}
effectiveEnd := periodEnd
if effectiveEnd.After(lastDay) {
effectiveEnd = lastDay
}
if effectiveStart.After(effectiveEnd) {
continue
}
if _, ok := emptyDaysByKandang[rec.KandangID]; !ok {
emptyDaysByKandang[rec.KandangID] = make(map[int]struct{})
}
for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) {
emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{}
}
}
}
for i, item := range items {
daySet := emptyDaysByKandang[item.KandangID]
for day := range daySet {
key := strconv.Itoa(day)
if _, exists := items[i].DailyActivities[key]; !exists {
items[i].DailyActivities[key] = "Kandang kosong"
}
}
}
}
return items, total, nil
return allItems, total, nil
}
@@ -208,8 +208,18 @@ func TestCreateOneAllowsBulkEmptyKandangWhenRangeHasOnlySoftDeletedChecklist(t *
Count(&activeInRange).Error; err != nil {
t.Fatalf("failed counting checklists in range: %v", err)
}
if activeInRange != 5 {
t.Fatalf("expected 5 active checklists created for range, got %d", activeInRange)
if activeInRange != 1 {
t.Fatalf("expected 1 active empty_kandang checklist created for range, got %d", activeInRange)
}
var emptyRangeCount int64
if err := db.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date = ? AND end_date = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
Count(&emptyRangeCount).Error; err != nil {
t.Fatalf("failed counting empty kandang ranges: %v", err)
}
if emptyRangeCount != 1 {
t.Fatalf("expected 1 empty kandang range record for [2026-01-01, 2026-01-05], got %d", emptyRangeCount)
}
}
@@ -304,6 +314,18 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE daily_checklist_empty_kandangs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
daily_checklist_id INTEGER NOT NULL,
kandang_id INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
created_by INTEGER NULL,
deleted_by INTEGER NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`INSERT INTO areas (id, name, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Area A', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO locations (id, name, address, area_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Farm A', 'Address', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO kandang_groups (id, name, status, location_id, pic_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Kandang A', 'ACTIVE', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
@@ -316,7 +338,8 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.
}
repo := repository.NewDailyChecklistRepository(db)
svc := NewDailyChecklistService(repo, nil, validator.New(), nil)
emptyRepo := repository.NewDailyChecklistEmptyKandangRepository(db)
svc := NewDailyChecklistService(repo, emptyRepo, nil, validator.New(), nil)
return svc, db
}
@@ -10,6 +10,7 @@ type Create struct {
Category string `json:"category" validate:"required"`
Status string `json:"status" validate:"required"`
EmptyKandang bool `json:"empty_kandang"`
EmptyKandangEndDate string `json:"empty_kandang_end_date" validate:"omitempty"`
}
type Update struct {
@@ -119,9 +119,9 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context,
var rows []RecordingWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE (r.day / 7 + 1)
END`
db := r.DB().WithContext(ctx).
@@ -503,9 +503,9 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context
var rows []ComparisonWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE (r.day / 7 + 1)
END`
db := r.DB().WithContext(ctx).
@@ -574,9 +574,9 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
var rows []EggQualityWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE (r.day / 7 + 1)
END`
db := r.DB().WithContext(ctx).
@@ -616,9 +616,9 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s
var rows []WeeklyEggWeightMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE (r.day / 7 + 1)
END`
db := r.DB().WithContext(ctx).
@@ -647,9 +647,9 @@ func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, s
var rows []WeeklyFeedUsageMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE (r.day / 7 + 1)
END`
db := r.DB().WithContext(ctx).
@@ -65,6 +65,8 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")),
ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)),
SortBy: strings.TrimSpace(c.Query("sort_by", "")),
SortOrder: strings.TrimSpace(c.Query("sort_order", "")),
}
if isAllExpenseExcelExportRequest(c) {
@@ -481,6 +483,27 @@ func (u *ExpenseController) CompleteExpense(c *fiber.Ctx) error {
})
}
func (u *ExpenseController) Pay(c *fiber.Ctx) error {
expenseID := c.Params("id")
id, err := strconv.Atoi(expenseID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID")
}
expense, err := u.ExpenseService.Pay(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Pay expense successfully",
Data: expense,
})
}
func ensureExpenseBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error {
requiredPerms := []string{}
@@ -29,6 +29,7 @@ type ExpenseBaseDTO struct {
RealizationDate *time.Time `json:"realization_date,omitempty"`
TransactionDate time.Time `json:"transaction_date"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
IsPaid bool `json:"is_paid"`
}
type ExpenseListDTO struct {
@@ -86,6 +87,7 @@ type KandangGroupDTO struct {
type DocumentDTO struct {
ID uint64 `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
}
// === MAPPERS ===
@@ -126,6 +128,7 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO {
RealizationDate: realizationDate,
TransactionDate: e.TransactionDate,
Location: location,
IsPaid: e.IsPaid,
}
}
@@ -184,6 +187,7 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
documents = append(documents, DocumentDTO{
ID: uint64(doc.Id),
Path: doc.Path,
Name: doc.Name,
})
}
@@ -191,6 +195,7 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
realizationDocs = append(realizationDocs, DocumentDTO{
ID: uint64(doc.Id),
Path: doc.Path,
Name: doc.Name,
})
}
@@ -98,6 +98,7 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
return db.
Preload("Expense").
Preload("Expense.Supplier").
Preload("Expense.Location").
Preload("Kandang").
Preload("Kandang.Location").
Preload("Nonstock").
@@ -177,10 +178,48 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
return nil, 0, err
}
sortExpr := "expense_realizations.created_at"
order := "DESC"
if filters.SortOrder == "asc" {
order = "ASC"
}
switch filters.SortBy {
case "po_number":
sortExpr = "expenses.po_number"
case "reference_number":
sortExpr = "expenses.reference_number"
case "realization_date":
sortExpr = "expenses.realization_date"
case "transaction_date":
sortExpr = "expenses.transaction_date"
case "category":
sortExpr = "expenses.category"
case "product":
sortExpr = "(SELECT name FROM nonstocks WHERE id = expense_nonstocks.nonstock_id)"
case "supplier":
sortExpr = "suppliers.name"
case "location":
sortExpr = "(SELECT l.name FROM kandangs k JOIN locations l ON l.id = k.location_id WHERE k.id = expense_nonstocks.kandang_id)"
case "kandang":
sortExpr = "(SELECT name FROM kandangs WHERE id = expense_nonstocks.kandang_id)"
case "qty_pengajuan":
sortExpr = "expense_nonstocks.qty"
case "price_pengajuan":
sortExpr = "expense_nonstocks.price"
case "total_pengajuan":
sortExpr = "expense_nonstocks.qty * expense_nonstocks.price"
case "qty_realisasi":
sortExpr = "expense_realizations.qty"
case "price_realisasi":
sortExpr = "expense_realizations.price"
case "total_realisasi":
sortExpr = "expense_realizations.qty * expense_realizations.price"
}
if err := db.
Offset(offset).
Limit(limit).
Order("expense_realizations.created_at DESC").
Order(sortExpr + " " + order).
Find(&realizations).Error; err != nil {
return nil, 0, err
}
+1
View File
@@ -36,6 +36,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService
route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization)
route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization)
route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense)
route.Patch("/:id/pay", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.Pay)
route.Delete("/:id/documents/:documentId", m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument)
route.Delete("/:id/realization-documents/:documentId", m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument)
}
@@ -36,6 +36,7 @@ type ExpenseService interface {
DeleteOne(ctx *fiber.Ctx, id uint64) error
CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error)
CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error)
Pay(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error)
UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error)
DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error
Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error)
@@ -288,7 +289,40 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
like,
)
}
sortBy := strings.TrimSpace(params.SortBy)
sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder))
if sortOrder == "" {
sortOrder = "DESC"
}
switch sortBy {
case "reference_number":
return db.Order("expenses.reference_number " + sortOrder)
case "transaction_date":
return db.Order("expenses.transaction_date " + sortOrder)
case "realization_date":
return db.Order("expenses.realization_date " + sortOrder)
case "location":
return db.Order("(SELECT COALESCE(name,'') FROM locations WHERE id = expenses.location_id) " + sortOrder)
case "created_user":
return db.Order("(SELECT COALESCE(name,'') FROM users WHERE id = expenses.created_by) " + sortOrder)
case "supplier":
return db.Order("(SELECT COALESCE(name,'') FROM suppliers WHERE id = expenses.supplier_id) " + sortOrder)
case "grand_total":
return db.Order(`(SELECT COALESCE(
(SELECT SUM(er.qty * er.price) FROM expense_realizations er
JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id
WHERE en.expense_id = expenses.id),
(SELECT SUM(en2.qty * en2.price) FROM expense_nonstocks en2
WHERE en2.expense_id = expenses.id),
0)) ` + sortOrder)
case "is_paid":
return db.Order("expenses.is_paid " + sortOrder)
case "created_at":
return db.Order("expenses.created_at " + sortOrder)
default:
return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
}
})
if scopeErr != nil {
@@ -342,6 +376,18 @@ func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetail
expense.LatestApproval = approval
responseDTO := expenseDto.ToExpenseDetailDTO(expense)
for i := range responseDTO.Documents {
if url, err := commonSvc.ResolveDocumentURL(c.Context(), s.DocumentSvc, responseDTO.Documents[i].Path, 15*time.Minute); err == nil && url != "" {
responseDTO.Documents[i].Path = url
}
}
for i := range responseDTO.RealizationDocs {
if url, err := commonSvc.ResolveDocumentURL(c.Context(), s.DocumentSvc, responseDTO.RealizationDocs[i].Path, 15*time.Minute); err == nil && url != "" {
responseDTO.RealizationDocs[i].Path = url
}
}
return &responseDTO, nil
}
@@ -1298,6 +1344,41 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (
return responseDTO, nil
}
func (s *expenseService) Pay(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) {
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
); err != nil {
return nil, err
}
expense, err := s.Repository.GetByID(c.Context(), id, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
}
if expense.IsPaid {
return nil, fiber.NewError(fiber.StatusBadRequest, "Expense is already paid")
}
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, func(db *gorm.DB) *gorm.DB {
return db.Where("action = ?", entity.ApprovalActionApproved)
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
}
if latestApproval == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Expense must be approved by Finance (step 4) before payment")
}
if latestApproval.StepNumber < uint16(utils.ExpenseStepFinance) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Expense must be approved by Finance (step 4) before payment")
}
if err := s.Repository.PatchOne(c.Context(), id, map[string]any{"is_paid": true}, nil); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update payment status")
}
return s.GetOne(c, id)
}
func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil {
@@ -54,6 +54,8 @@ type Query struct {
RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"`
ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=reference_number transaction_date realization_date location created_user supplier grand_total is_paid created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"`
}
type CreateRealization struct {
@@ -5,6 +5,7 @@ import (
"strconv"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations"
@@ -13,6 +14,8 @@ import (
"github.com/gofiber/fiber/v2"
)
const transactionExcelExportFetchLimit = 99999999
type TransactionController struct {
TransactionService service.TransactionService
}
@@ -97,6 +100,8 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error {
CustomerIDs: customerIDs,
SupplierIDs: supplierIDs,
SortDate: c.Query("sort_date", ""),
SortBy: c.Query("sort_by", ""),
SortOrder: c.Query("sort_order", ""),
StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""),
}
@@ -105,6 +110,14 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
if isTransactionExcelExportRequest(c) {
results, err := u.getAllTransactionsForExcel(c, query)
if err != nil {
return err
}
return exportTransactionListExcel(c, results)
}
result, totalResults, err := u.TransactionService.GetAll(c, query)
if err != nil {
return err
@@ -147,6 +160,32 @@ func (u *TransactionController) GetOne(c *fiber.Ctx) error {
})
}
func isTransactionExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func (u *TransactionController) getAllTransactionsForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]entity.Payment, error) {
query := *baseQuery
query.Page = 1
query.Limit = transactionExcelExportFetchLimit
results := make([]entity.Payment, 0)
for {
pageResults, total, err := u.TransactionService.GetAll(c, &query)
if err != nil {
return nil, err
}
if len(pageResults) == 0 || total == 0 {
break
}
results = append(results, pageResults...)
if int64(len(results)) >= total {
break
}
query.Page++
}
return results, nil
}
func (u *TransactionController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
@@ -0,0 +1,307 @@
package controller
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
const transactionExportSheetName = "Transaksi"
func exportTransactionListExcel(c *fiber.Ctx, payments []entity.Payment) error {
content, err := buildTransactionExportWorkbook(payments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("transaksi_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func buildTransactionExportWorkbook(payments []entity.Payment) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != transactionExportSheetName {
if err := file.SetSheetName(defaultSheet, transactionExportSheetName); err != nil {
return nil, err
}
}
if err := setTransactionExportColumns(file); err != nil {
return nil, err
}
if err := setTransactionExportHeaders(file); err != nil {
return nil, err
}
if err := setTransactionExportRows(file, payments); err != nil {
return nil, err
}
if err := file.SetPanes(transactionExportSheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func setTransactionExportColumns(file *excelize.File) error {
columnWidths := map[string]float64{
"A": 20,
"B": 22,
"C": 18,
"D": 25,
"E": 14,
"F": 16,
"G": 16,
"H": 22,
"I": 22,
"J": 18,
"K": 18,
"L": 18,
"M": 30,
"N": 22,
"O": 20,
}
sheet := transactionExportSheetName
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return file.SetRowHeight(sheet, 1, 24)
}
func setTransactionExportHeaders(file *excelize.File) error {
sheet := transactionExportSheetName
headers := []string{
"Kode Pembayaran",
"No. Referensi",
"Tipe Transaksi",
"Pihak",
"Tipe Pihak",
"Tanggal Bayar",
"Metode Bayar",
"Bank",
"No. Rekening Bank",
"Pemasukan",
"Pengeluaran",
"Nominal",
"Catatan",
"Dibuat Oleh",
"Status",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+"1", header); err != nil {
return err
}
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
return file.SetCellStyle(sheet, "A1", "O1", headerStyle)
}
func setTransactionExportRows(file *excelize.File, payments []entity.Payment) error {
if len(payments) == 0 {
return nil
}
sheet := transactionExportSheetName
for i, p := range payments {
row := strconv.Itoa(i + 2)
if err := writeTransactionExportRow(file, sheet, row, p); err != nil {
return err
}
}
lastRow := strconv.Itoa(len(payments) + 1)
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A2", "O"+lastRow, dataStyle); err != nil {
return err
}
numericStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "right", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
return file.SetCellStyle(sheet, "J2", "L"+lastRow, numericStyle)
}
func writeTransactionExportRow(file *excelize.File, sheet, row string, p entity.Payment) error {
incomeAmount, expenseAmount := txAmounts(p.Direction, p.Nominal)
values := []interface{}{
safeTxText(p.PaymentCode),
safeTxRefNumber(p.ReferenceNumber),
safeTxText(txTransactionType(p)),
safeTxText(txPartyName(p)),
safeTxText(p.PartyType),
formatTxDate(p.PaymentDate),
safeTxText(p.PaymentMethod),
safeTxBank(p),
safeTxBankAccount(p),
incomeAmount,
expenseAmount,
p.Nominal,
safeTxText(p.Notes),
safeTxText(txCreatedBy(p)),
formatTxStatus(p),
}
for colIdx, val := range values {
colName, err := excelize.ColumnNumberToName(colIdx + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+row, val); err != nil {
return err
}
}
return nil
}
func safeTxText(s string) string {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return "-"
}
return trimmed
}
func safeTxRefNumber(s *string) string {
if s == nil {
return "-"
}
return safeTxText(*s)
}
func safeTxBank(p entity.Payment) string {
if p.BankWarehouse.Id == 0 {
return "-"
}
return safeTxText(p.BankWarehouse.Name)
}
func safeTxBankAccount(p entity.Payment) string {
if p.BankWarehouse.Id == 0 {
return "-"
}
return safeTxText(p.BankWarehouse.AccountNumber)
}
func formatTxDate(t time.Time) string {
if t.IsZero() {
return "-"
}
loc, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(loc)
}
return t.Format("02-01-2006")
}
func formatTxStatus(p entity.Payment) string {
if p.LatestApproval == nil {
return "-"
}
return safeTxText(p.LatestApproval.StepName)
}
func txTransactionType(p entity.Payment) string {
if p.TransactionType != "" {
return p.TransactionType
}
return p.Direction
}
func txPartyName(p entity.Payment) string {
switch p.PartyType {
case "CUSTOMER":
if p.Customer != nil && p.Customer.Id != 0 {
return p.Customer.Name
}
case "SUPPLIER":
if p.Supplier != nil && p.Supplier.Id != 0 {
return p.Supplier.Name
}
}
return ""
}
func txCreatedBy(p entity.Payment) string {
if p.CreatedUser.Id == 0 {
return ""
}
return p.CreatedUser.Name
}
func txAmounts(direction string, nominal float64) (income, expense float64) {
switch strings.ToUpper(direction) {
case "IN":
return nominal, 0
case "OUT":
return 0, nominal
default:
return 0, 0
}
}
@@ -72,19 +72,26 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
needsPartyJoin := params.Search != "" || params.SortBy == "customer_name"
needsBankJoin := params.Search != "" || params.SortBy == "bank"
if needsPartyJoin {
db = db.Joins(
"LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL",
string(utils.PaymentPartyCustomer),
).Joins(
"LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL",
string(utils.PaymentPartySupplier),
).Joins(
"LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL",
)
}
if needsBankJoin {
db = db.Joins("LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL")
}
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
db = db.Where(
`LOWER(payment_code) LIKE ? OR
`(LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(payment_method, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR
@@ -93,7 +100,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
LOWER(COALESCE(suppliers.name, '')) LIKE ? OR
LOWER(COALESCE(banks.name, '')) LIKE ? OR
CAST(payments.nominal AS TEXT) LIKE ? OR
TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?`,
TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?)`,
like, like, like, like, like, like, like, like, like, like,
)
}
@@ -138,7 +145,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
db = db.Where("payment_date < ?", *endDate)
}
return applyTransactionSort(db, params.SortDate)
return applyTransactionSort(db, params.SortBy, params.SortOrder, params.SortDate)
})
if err != nil {
@@ -270,13 +277,39 @@ func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Tim
return startPtr, endPtr, nil
}
func applyTransactionSort(db *gorm.DB, sortDate string) *gorm.DB {
func applyTransactionSort(db *gorm.DB, sortBy, sortOrder, sortDate string) *gorm.DB {
order := "DESC"
if strings.ToUpper(strings.TrimSpace(sortOrder)) == "ASC" {
order = "ASC"
}
switch strings.ToLower(strings.TrimSpace(sortBy)) {
case "payment_code":
return db.Order("payments.payment_code " + order)
case "reference_number":
return db.Order("payments.reference_number " + order)
case "transaction_type":
return db.Order("payments.transaction_type " + order)
case "customer_name":
return db.Order("COALESCE(customers.name, suppliers.name) " + order)
case "payment_date":
return db.Order("payments.payment_date " + order)
case "created_at":
return db.Order("payments.created_at " + order)
case "payment_method":
return db.Order("payments.payment_method " + order)
case "bank":
return db.Order("banks.account_number " + order)
case "expense_amount":
return db.Order("CASE WHEN payments.direction = 'OUT' THEN payments.nominal ELSE 0 END " + order)
case "income_amount":
return db.Order("CASE WHEN payments.direction = 'IN' THEN payments.nominal ELSE 0 END " + order)
}
switch strings.ToLower(strings.TrimSpace(sortDate)) {
case "created_at":
return db.Order("created_at DESC").Order("payment_date DESC")
case "payment_date":
return db.Order("payment_date DESC").Order("created_at DESC")
return db.Order("payments.created_at DESC").Order("payments.payment_date DESC")
default:
return db.Order("payment_date DESC").Order("created_at DESC")
return db.Order("payments.payment_date DESC").Order("payments.created_at DESC")
}
}
@@ -10,13 +10,15 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"`
BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"`
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"`
SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=payment_code reference_number transaction_type customer_name payment_date created_at payment_method bank expense_amount income_amount"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
}
+2
View File
@@ -10,6 +10,7 @@ import (
adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments"
productStocks "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks"
productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses"
stockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs"
transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers"
// MODULE IMPORTS
)
@@ -23,6 +24,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida
adjustments.AdjustmentModule{},
transfers.TransferModule{},
productStocks.ProductStockModule{},
stockLogs.StockLogModule{},
// MODULE REGISTRY
}
@@ -0,0 +1,66 @@
package controller
import (
"math"
"strings"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/validations"
stockLogDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/dto"
stockLogService "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/services"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type StockLogController struct {
StockLogService stockLogService.StockLogService
}
func NewStockLogController(s stockLogService.StockLogService) *StockLogController {
return &StockLogController{
StockLogService: s,
}
}
func (u *StockLogController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
ProductWarehouseID: uint(c.QueryInt("product_warehouse_id", 0)),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
// Export to Excel
if strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") {
if query.ProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "product_warehouse_id is required for export")
}
results, err := u.StockLogService.GetAllForExport(c, query.ProductWarehouseID)
if err != nil {
return err
}
return exportStockLogListExcel(c, results)
}
result, totalResults, err := u.StockLogService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[stockLogDTO.StockLogListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all stock logs successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: stockLogDTO.ToStockLogListDTOs(result),
})
}
@@ -0,0 +1,118 @@
package controller
import (
"fmt"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
func exportStockLogListExcel(c *fiber.Ctx, stockLogs []entity.StockLog) error {
file := excelize.NewFile()
defer file.Close()
sheet := "Stock Logs"
file.SetSheetName("Sheet1", sheet)
headers := []string{
"ID",
"Tanggal",
"Gudang",
"Stok Akhir",
"Peningkatan",
"Penurunan",
"Jenis Transaksi",
"Catatan",
"Oleh",
}
// Column widths
colWidths := map[string]float64{
"A": 8,
"B": 20,
"C": 25,
"D": 14,
"E": 14,
"F": 14,
"G": 20,
"H": 30,
"I": 20,
}
for col, width := range colWidths {
file.SetColWidth(sheet, col, col, width)
}
// Header style
headerStyle, _ := file.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
Size: 11,
},
Fill: excelize.Fill{
Type: "pattern",
Pattern: 1,
Color: []string{"D9E1F2"},
},
Border: []excelize.Border{
{Type: "bottom", Style: 1, Color: "000000"},
},
})
// Write header row
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
file.SetCellValue(sheet, cell, h)
file.SetCellStyle(sheet, cell, cell, headerStyle)
}
// Freeze header row
file.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
})
// Write data rows
for i, log := range stockLogs {
row := i + 2
warehouseName := ""
if log.ProductWarehouse != nil {
warehouseName = log.ProductWarehouse.Warehouse.Name
}
userName := ""
if log.CreatedUser != nil {
userName = log.CreatedUser.Name
}
notes := ""
if log.Notes != "" {
notes = log.Notes
}
file.SetCellInt(sheet, fmt.Sprintf("A%d", row), int(log.Id))
file.SetCellValue(sheet, fmt.Sprintf("B%d", row), log.CreatedAt.Format("2006-01-02 15:04:05"))
file.SetCellValue(sheet, fmt.Sprintf("C%d", row), warehouseName)
file.SetCellFloat(sheet, fmt.Sprintf("D%d", row), log.Stock, 3, 64)
file.SetCellFloat(sheet, fmt.Sprintf("E%d", row), log.Increase, 3, 64)
file.SetCellFloat(sheet, fmt.Sprintf("F%d", row), log.Decrease, 3, 64)
file.SetCellValue(sheet, fmt.Sprintf("G%d", row), log.LoggableType)
file.SetCellValue(sheet, fmt.Sprintf("H%d", row), notes)
file.SetCellValue(sheet, fmt.Sprintf("I%d", row), userName)
}
buffer, err := file.WriteToBuffer()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("stock_logs_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(buffer.Bytes())
}
@@ -0,0 +1,61 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
type StockLogListDTO struct {
Id uint `json:"id"`
ProductWarehouseId uint `json:"product_warehouse_id"`
Increase float64 `json:"increase"`
Decrease float64 `json:"decrease"`
Stock float64 `json:"stock"`
LoggableType string `json:"loggable_type"`
LoggableId uint `json:"loggable_id"`
Notes *string `json:"notes"`
CreatedBy uint `json:"created_by"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func ToStockLogListDTO(e entity.StockLog) StockLogListDTO {
var notes *string
if e.Notes != "" {
n := e.Notes
notes = &n
}
var createdUser *userDTO.UserRelationDTO
if e.CreatedUser != nil && e.CreatedUser.Id != 0 {
mapped := userDTO.ToUserRelationDTO(*e.CreatedUser)
createdUser = &mapped
}
return StockLogListDTO{
Id: e.Id,
ProductWarehouseId: e.ProductWarehouseId,
Increase: e.Increase,
Decrease: e.Decrease,
Stock: e.Stock,
LoggableType: e.LoggableType,
LoggableId: e.LoggableId,
Notes: notes,
CreatedBy: e.CreatedBy,
CreatedUser: createdUser,
CreatedAt: e.CreatedAt,
}
}
func ToStockLogListDTOs(e []entity.StockLog) []StockLogListDTO {
if len(e) == 0 {
return []StockLogListDTO{}
}
result := make([]StockLogListDTO, len(e))
for i, log := range e {
result[i] = ToStockLogListDTO(log)
}
return result
}
@@ -0,0 +1,24 @@
package stockLogs
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
stockLogService "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/services"
stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type StockLogModule struct{}
func (StockLogModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
userRepo := rUser.NewUserRepository(db)
userService := sUser.NewUserService(userRepo, validate)
stockLogRepo := stockLogRepo.NewStockLogRepository(db)
service := stockLogService.NewStockLogService(stockLogRepo, validate)
StockLogRoutes(router, userService, service)
}
@@ -0,0 +1,19 @@
package stockLogs
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/controllers"
stockLog "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func StockLogRoutes(v1 fiber.Router, u user.UserService, s stockLog.StockLogService) {
ctrl := controller.NewStockLogController(s)
route := v1.Group("/stock-logs")
route.Use(m.Auth(u))
route.Get("/", m.RequirePermissions(m.P_StockLogGetAll), ctrl.GetAll)
}
@@ -0,0 +1,125 @@
package service
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/validations"
stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type StockLogService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockLog, int64, error)
GetAllForExport(ctx *fiber.Ctx, productWarehouseID uint) ([]entity.StockLog, error)
}
type stockLogService struct {
Log *logrus.Logger
Validate *validator.Validate
StockLogRepo stockLogRepo.StockLogRepository
}
func NewStockLogService(
stockLogRepo stockLogRepo.StockLogRepository,
validate *validator.Validate,
) StockLogService {
return &stockLogService{
Log: utils.Log,
Validate: validate,
StockLogRepo: stockLogRepo,
}
}
func (s *stockLogService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockLog, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.StockLogRepo.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
stockLogs, total, err := s.StockLogRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = db.Where("product_warehouse_id = ?", params.ProductWarehouseID)
if locationScope.Restrict || areaScope.Restrict {
if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) {
return db.Where("1 = 0")
}
db = db.
Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id")
if locationScope.Restrict {
db = db.Where("w.location_id IN ?", locationScope.IDs)
}
if areaScope.Restrict {
db = db.Where("w.area_id IN ?", areaScope.IDs)
}
}
db = db.
Preload("CreatedUser").
Order("stock_logs.created_at DESC")
return db
})
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return nil, 0, err
}
if total == 0 {
return []entity.StockLog{}, 0, nil
}
return stockLogs, total, nil
}
func (s *stockLogService) GetAllForExport(c *fiber.Ctx, productWarehouseID uint) ([]entity.StockLog, error) {
locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.StockLogRepo.DB())
if err != nil {
return nil, err
}
stockLogs, _, err := s.StockLogRepo.GetAll(c.Context(), 0, -1, func(db *gorm.DB) *gorm.DB {
db = db.Where("product_warehouse_id = ?", productWarehouseID)
if locationScope.Restrict || areaScope.Restrict {
if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) {
return db.Where("1 = 0")
}
db = db.
Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id")
if locationScope.Restrict {
db = db.Where("w.location_id IN ?", locationScope.IDs)
}
if areaScope.Restrict {
db = db.Where("w.area_id IN ?", areaScope.IDs)
}
}
db = db.
Preload("CreatedUser").
Preload("ProductWarehouse").
Preload("ProductWarehouse.Warehouse").
Order("stock_logs.created_at ASC")
return db
})
if err != nil {
s.Log.Errorf("Failed to get stock logs for export: %+v", err)
return nil, err
}
return stockLogs, nil
}
@@ -0,0 +1,7 @@
package validation
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"`
ProductWarehouseID uint `query:"product_warehouse_id" validate:"required,gt=0"`
}
@@ -56,6 +56,12 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
}
sortBy := strings.TrimSpace(c.Query("sort_by", ""))
sortOrder := strings.TrimSpace(c.Query("sort_order", ""))
if sortOrder == "" {
sortOrder = "asc"
}
query := &validation.DeliveryOrderQuery{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
@@ -66,6 +72,8 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
MarketingId: uint(c.QueryInt("marketing_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
SortBy: sortBy,
SortOrder: sortOrder,
}
if isAllExcelExportRequest(c) {
@@ -2,7 +2,6 @@ package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -76,9 +75,18 @@ func setMarketingExportColumns(file *excelize.File, sheet string) error {
"B": 14,
"C": 18,
"D": 20,
"E": 18,
"F": 60,
"G": 24,
"E": 14,
"F": 40,
"G": 10,
"H": 12,
"I": 12,
"J": 12,
"K": 16,
"L": 16,
"M": 18,
"N": 18,
"O": 18,
"P": 24,
}
for col, width := range columnWidths {
@@ -96,13 +104,22 @@ func setMarketingExportColumns(file *excelize.File, sheet string) error {
func setMarketingExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"No. Order",
"Tanggal",
"Status",
"Customer",
"Grand Total",
"Products",
"Notes",
"No. Order", // A
"Tanggal", // B
"Status", // C
"Customer", // D
"Tipe", // E
"Nama Produk", // F
"Week", // G
"Jumlah", // H
"Satuan", // I
"Qty Peti", // J
"Berat Rata-rata (kg)", // K
"Total Berat (kg)", // L
"Harga Satuan", // M
"Total Harga", // N
"Grand Total", // O
"Catatan", // P
}
for i, header := range headers {
@@ -131,7 +148,7 @@ func setMarketingExportHeaders(file *excelize.File, sheet string) error {
return err
}
return file.SetCellStyle(sheet, "A1", "G1", headerStyle)
return file.SetCellStyle(sheet, "A1", "P1", headerStyle)
}
func setMarketingExportRows(file *excelize.File, sheet string, items []dto.MarketingListDTO) error {
@@ -139,70 +156,154 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
return nil
}
for i, item := range items {
rowNumber := i + 2
if err := file.SetCellValue(sheet, "A"+strconv.Itoa(rowNumber), safeMarketingExportText(item.SoNumber)); err != nil {
row := 1
for _, item := range items {
soNumber := safeMarketingExportText(item.SoNumber)
soDate := formatMarketingExportDate(item.SoDate)
status := formatMarketingExportStatus(item)
customer := safeMarketingExportText(item.Customer.Name)
grandTotal := sumMarketingGrandTotal(item.SalesOrder)
notes := safeMarketingExportText(item.Notes)
if len(item.SalesOrder) == 0 {
row++
r := strconv.Itoa(row)
vals := map[string]interface{}{
"A": soNumber, "B": soDate, "C": status, "D": customer,
"E": "-", "F": "-", "G": "-", "H": "-", "I": "-", "J": "-",
"K": "-", "L": "-", "M": "-", "N": "-",
"O": grandTotal, "P": notes,
}
for col, val := range vals {
if err := file.SetCellValue(sheet, col+r, val); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+strconv.Itoa(rowNumber), formatMarketingExportDate(item.SoDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+strconv.Itoa(rowNumber), formatMarketingExportStatus(item)); err != nil {
return err
continue
}
if err := file.SetCellValue(sheet, "D"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Customer.Name)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), formatMarketingRupiah(sumMarketingGrandTotal(item.SalesOrder))); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Notes)); err != nil {
return err
for _, prod := range item.SalesOrder {
row++
r := strconv.Itoa(row)
productName := "-"
if prod.ProductWarehouse != nil && prod.ProductWarehouse.Product != nil {
if n := strings.TrimSpace(prod.ProductWarehouse.Product.Name); n != "" {
productName = n
}
}
lastRow := len(items) + 1
week := "-"
if prod.Week != nil {
week = strconv.Itoa(*prod.Week)
}
satuan := "-"
if prod.ConvertionUnit != nil && strings.TrimSpace(*prod.ConvertionUnit) != "" {
satuan = *prod.ConvertionUnit
}
if err := file.SetCellValue(sheet, "A"+r, soNumber); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+r, soDate); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+r, status); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+r, customer); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+r, safeMarketingExportText(prod.MarketingType)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+r, productName); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+r, week); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+r, prod.Qty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+r, satuan); err != nil {
return err
}
if prod.TotalPeti != nil {
if err := file.SetCellValue(sheet, "J"+r, *prod.TotalPeti); err != nil {
return err
}
} else {
if err := file.SetCellValue(sheet, "J"+r, "-"); err != nil {
return err
}
}
if err := file.SetCellValue(sheet, "K"+r, prod.AvgWeight); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+r, prod.TotalWeight); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+r, prod.UnitPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+r, prod.TotalPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+r, grandTotal); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+r, notes); err != nil {
return err
}
}
}
lastRow := row
lastRowStr := strconv.Itoa(lastRow)
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},
}
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
WrapText: true,
},
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},
},
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center", WrapText: true},
Border: border,
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A2", "G"+strconv.Itoa(lastRow), dataStyle); err != nil {
if err := file.SetCellStyle(sheet, "A2", "P"+lastRowStr, dataStyle); err != nil {
return err
}
moneyStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "right",
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},
},
numberStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "right", Vertical: "center"},
Border: border,
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "K2", "O"+lastRowStr, numberStyle); err != nil {
return err
}
return file.SetCellStyle(sheet, "E2", "E"+strconv.Itoa(lastRow), moneyStyle)
centerStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: border,
})
if err != nil {
return err
}
for _, col := range []string{"G", "H", "J"} {
if err := file.SetCellStyle(sheet, col+"2", col+lastRowStr, centerStyle); err != nil {
return err
}
}
return nil
}
func formatMarketingExportDate(value time.Time) string {
@@ -226,36 +327,6 @@ func formatMarketingExportStatus(item dto.MarketingListDTO) string {
return safeMarketingExportText(item.LatestApproval.StepName)
}
func formatMarketingProducts(items []dto.DeliveryMarketingProductDTO) string {
if len(items) == 0 {
return "-"
}
seen := make(map[string]struct{})
names := make([]string, 0, len(items))
for _, item := range items {
if item.ProductWarehouse == nil || item.ProductWarehouse.Product == nil {
continue
}
name := strings.TrimSpace(item.ProductWarehouse.Product.Name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return "-"
}
return strings.Join(names, ", ")
}
func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 {
total := 0.0
@@ -266,40 +337,6 @@ func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 {
return total
}
func formatMarketingRupiah(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) {
return "Rp 0"
}
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
}
}
for i := rem; i < len(raw); i += 3 {
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
}
func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value)
@@ -29,6 +29,7 @@ type MarketingListDTO struct {
SalesPerson userDTO.UserRelationDTO `json:"sales_person"`
SoDocs string `json:"so_docs"`
SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"`
DeliveryOrder []DeliveryGroupDTO `json:"delivery_order"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -203,6 +204,7 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M
SalesPerson: salesPerson,
SoDocs: marketing.SoDocs,
SalesOrder: salesOrderProducts,
DeliveryOrder: extractDeliveryGroupsFromProducts(marketing),
CreatedUser: createdUser,
CreatedAt: marketing.CreatedAt,
UpdatedAt: marketing.UpdatedAt,
@@ -376,6 +378,23 @@ func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, wareh
return numberPrefix
}
func extractDeliveryGroupsFromProducts(marketing *entity.Marketing) []DeliveryGroupDTO {
var dps []MarketingDeliveryProductDTO
for _, product := range marketing.Products {
if product.DeliveryProduct == nil || product.DeliveryProduct.DeliveryDate == nil {
continue
}
dp := ToMarketingDeliveryProductDTO(*product.DeliveryProduct)
if product.ProductWarehouse.Id != 0 {
mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(product.ProductWarehouse)
dp.ProductWarehouse = &mapped
}
dp.ConvertionUnit = product.ConvertionUnit
dps = append(dps, dp)
}
return groupDeliveryProducts(dps, marketing.SoNumber)
}
func collectDoNumbers(marketing *entity.Marketing) []string {
if marketing == nil || len(marketing.Products) == 0 {
return nil
@@ -292,7 +292,29 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
if params.MarketingId != 0 {
return db.Where("id = ?", params.MarketingId)
}
orderDir := "DESC"
if params.SortOrder != "" {
orderDir = strings.ToUpper(params.SortOrder)
}
switch strings.TrimSpace(params.SortBy) {
case "so_number":
return db.Order("marketings.so_number " + orderDir)
case "so_date":
return db.Order("marketings.so_date " + orderDir)
case "status":
statusSQL := "(SELECT step_name FROM approvals WHERE approvable_type = '" + utils.ApprovalWorkflowMarketing.String() + "' AND approvable_id = marketings.id ORDER BY action_at DESC, id DESC LIMIT 1) " + orderDir
return db.Order(statusSQL)
case "customer":
return db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id").Order("COALESCE(customers.name, '') " + orderDir)
case "grand_total":
return db.Order("(SELECT COALESCE(SUM(mp.total_price), 0) FROM marketing_products mp WHERE mp.marketing_id = marketings.id) " + orderDir)
case "created_at":
return db.Order("marketings.created_at " + orderDir)
default:
return db.Order("created_at DESC").Order("updated_at DESC")
}
})
if err != nil {
return nil, 0, err
@@ -520,9 +542,15 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil, err
}
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status")
}
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction)
marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil)
@@ -608,6 +636,23 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
}
}
if latestApproval != nil && latestApproval.StepNumber == uint16(utils.MarketingDeliveryOrder) {
action := entity.ApprovalActionUpdated
_, err := approvalSvcTx.CreateApproval(
c.Context(),
utils.ApprovalWorkflowMarketing,
id,
utils.MarketingStepSalesOrder,
&action,
actorID,
nil)
if err != nil {
if !errors.Is(err, gorm.ErrDuplicatedKey) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval to Sales Order")
}
}
}
return nil
})
if err != nil {
@@ -516,7 +516,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
c.Context(),
utils.ApprovalWorkflowMarketing,
id,
approvalutils.ApprovalStep(latestApproval.StepNumber),
utils.MarketingStepPengajuan,
&action,
actorID,
nil)
@@ -770,7 +770,12 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
totalWeight, totalPrice := s.calculatePriceByMarketingType(
var totalWeight, totalPrice float64
if rp.TotalPrice != nil {
totalWeight = math.Round(rp.Qty*rp.AvgWeight*100) / 100
totalPrice = *rp.TotalPrice
} else {
totalWeight, totalPrice = s.calculatePriceByMarketingType(
marketingType,
rp.Qty,
rp.AvgWeight,
@@ -779,6 +784,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
rp.ConvertionUnit,
rp.WeightPerConvertion,
)
}
marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId,
@@ -821,7 +827,7 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string,
totalPrice = math.Round(qty*unitPrice*100) / 100
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
totalWeight = math.Round(qty*avgWeight*100) / 100
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
} else {
totalWeight = math.Round(qty*avgWeight*100) / 100
@@ -31,6 +31,8 @@ type DeliveryOrderQuery struct {
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
}
type DeliveryOrderApprove struct {
@@ -26,6 +26,7 @@ type CreateMarketingProduct struct {
UnitPrice float64 `json:"unit_price" validate:"required,gt=0"`
Qty float64 `json:"qty" validate:"required,gt=0"`
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gt=0"`
TotalPrice *float64 `json:"total_price" validate:"omitempty,gt=0"`
}
type Update struct {
@@ -14,6 +14,7 @@ type CustomerRelationDTO struct {
Name string `json:"name"`
Type string `json:"type"`
AccountNumber string `json:"account_number"`
BankName string `json:"bank_name"`
Address string `json:"address,omitempty"`
Balance float64 `json:"balance"`
Pic *userDTO.UserRelationDTO `json:"pic,omitempty"`
@@ -28,6 +29,7 @@ type CustomerListDTO struct {
Phone string `json:"phone"`
Email string `json:"email"`
AccountNumber string `json:"account_number"`
BankName string `json:"bank_name"`
Balance float64 `json:"balance"`
Pic userDTO.UserRelationDTO `json:"pic"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"`
@@ -53,6 +55,7 @@ func ToCustomerRelationDTO(e entity.Customer) CustomerRelationDTO {
Name: e.Name,
Type: e.Type,
AccountNumber: e.AccountNumber,
BankName: e.BankName,
Address: e.Address,
Balance: e.Balance,
Pic: pic,
@@ -81,6 +84,7 @@ func ToCustomerListDTO(e entity.Customer) CustomerListDTO {
Phone: e.Phone,
Email: e.Email,
AccountNumber: e.AccountNumber,
BankName: e.BankName,
Pic: pic,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
@@ -133,6 +133,7 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti
Phone: req.Phone,
Email: req.Email,
AccountNumber: req.AccountNumber,
BankName: req.BankName,
CreatedBy: actorID,
}
@@ -193,6 +194,10 @@ func (s customerService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
updateBody["account_number"] = *req.AccountNumber
}
if req.BankName != nil {
updateBody["bank_name"] = *req.BankName
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
@@ -8,6 +8,7 @@ type Create struct {
Phone string `json:"phone" validate:"required_strict,max=20"`
Email string `json:"email" validate:"required_strict,email,max=50"`
AccountNumber string `json:"account_number" validate:"required_strict,max=50"`
BankName string `json:"bank_name" validate:"required_strict,max=100"`
}
type Update struct {
@@ -18,6 +19,7 @@ type Update struct {
Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"`
Email *string `json:"email,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
}
type Query struct {
@@ -27,6 +27,8 @@ func (u *EmployeesController) GetAll(c *fiber.Ctx) error {
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
OrderBy: c.Query("order_by", "desc"),
SortBy: c.Query("sort_by", "updated_at"),
}
if query.Page < 1 || query.Limit < 1 {
@@ -2,6 +2,7 @@ package service
import (
"errors"
"fmt"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -126,11 +127,18 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
if params.IsActive != nil {
db = db.Where("employees.is_active = ?", *params.IsActive)
}
return db.
db = db.
Select("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at").
Group("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at").
Order("employees.created_at DESC").
Order("employees.updated_at DESC")
Group("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at")
if params.OrderBy == "desc" || params.OrderBy == "" {
db = db.Order(fmt.Sprintf("employees.%s DESC", params.SortBy))
} else {
db = db.Order(fmt.Sprintf("employees.%s ASC", params.SortBy))
}
return db
})
if err != nil {
@@ -18,4 +18,6 @@ type Query struct {
Search string `query:"search" validate:"omitempty,max=50"`
KandangId *uint `query:"kandang_id" validate:"omitempty"`
IsActive *bool `query:"is_active" validate:"omitempty"`
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"`
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"`
}
@@ -29,6 +29,8 @@ func (u *KandangGroupController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""),
LocationId: c.QueryInt("location_id", 0),
PicId: c.QueryInt("pic_id", 0),
OrderBy: c.Query("order_by", "desc"),
SortBy: c.Query("sort_by", "updated_at"),
}
if query.Page < 1 || query.Limit < 1 {
@@ -70,7 +70,14 @@ func (s kandangGroupService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
if params.PicId != 0 {
db = db.Where("kandang_groups.pic_id = ?", params.PicId)
}
return db.Order("kandang_groups.created_at DESC").Order("kandang_groups.updated_at DESC")
if params.OrderBy == "desc" || params.OrderBy == "" {
db = db.Order(fmt.Sprintf("kandang_groups.%s DESC", params.SortBy))
} else {
db = db.Order(fmt.Sprintf("kandang_groups.%s ASC", params.SortBy))
}
return db
})
if scopeErr != nil {
@@ -20,4 +20,6 @@ type Query struct {
Search string `query:"search" validate:"omitempty,max=50"`
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"`
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"`
}
@@ -29,6 +29,8 @@ func (u *KandangController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""),
LocationId: c.QueryInt("location_id", 0),
PicId: c.QueryInt("pic_id", 0),
OrderBy: c.Query("order_by", "desc"),
SortBy: c.Query("sort_by", "created_at"),
}
if query.Page < 1 || query.Limit < 1 {
@@ -66,7 +66,14 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
if params.PicId != 0 {
db = db.Where("pic_id = ?", params.PicId)
}
return db.Order("created_at DESC").Order("updated_at DESC")
if params.OrderBy == "desc" || params.OrderBy == "" {
db = db.Order(fmt.Sprintf("%s DESC", params.SortBy))
} else {
db = db.Order(fmt.Sprintf("%s ASC", params.SortBy))
}
return db
})
if scopeErr != nil {
@@ -26,4 +26,6 @@ type Query struct {
Search string `query:"search" validate:"omitempty,max=50"`
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"`
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"`
}
@@ -26,6 +26,7 @@ type SupplierListDTO struct {
Address string `json:"address"`
Npwp *string `json:"npwp,omitempty"`
AccountNumber *string `json:"account_number,omitempty"`
BankName *string `json:"bank_name,omitempty"`
Balance float64 `json:"balance"`
DueDate int `json:"due_date"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
@@ -66,6 +67,7 @@ func ToSupplierListDTO(e entity.Supplier) SupplierListDTO {
Address: e.Address,
Npwp: e.Npwp,
AccountNumber: e.AccountNumber,
BankName: e.BankName,
Balance: e.Balance,
DueDate: e.DueDate,
SupplierRelationDTO: ToSupplierRelationDTO(e),
@@ -160,6 +160,7 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti
Address: req.Address,
Npwp: req.Npwp,
AccountNumber: req.AccountNumber,
BankName: req.BankName,
DueDate: req.DueDate,
CreatedBy: actorID,
}
@@ -243,6 +244,10 @@ func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
updateBody["account_number"] = *req.AccountNumber
}
if req.BankName != nil {
updateBody["bank_name"] = *req.BankName
}
if req.DueDate != nil {
updateBody["due_date"] = *req.DueDate
}
@@ -12,6 +12,7 @@ type Create struct {
Address string `json:"address" validate:"required_strict"`
Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
DueDate int `json:"due_date" validate:"required_strict,number,gt=0"`
}
@@ -27,6 +28,7 @@ type Update struct {
Address *string `json:"address,omitempty" validate:"omitempty"`
Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
DueDate *int `json:"due_date,omitempty" validate:"omitempty,number,gt=0"`
}
@@ -1,6 +1,7 @@
package controller
import (
"math"
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto"
@@ -21,32 +22,32 @@ func NewChickinController(chickinService service.ChickinService) *ChickinControl
}
}
// func (u *ChickinController) GetAll(c *fiber.Ctx) error {
// query := &validation.Query{
// Page: c.QueryInt("page", 1),
// Limit: c.QueryInt("limit", 10),
// ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)),
// }
func (u *ChickinController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)),
}
// result, totalResults, err := u.ChickinService.GetAll(c, query)
// if err != nil {
// return err
// }
result, totalResults, err := u.ChickinService.GetAll(c, query)
if err != nil {
return err
}
// return c.Status(fiber.StatusOK).
// JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{
// Code: fiber.StatusOK,
// Status: "success",
// Message: "Get all chickins successfully",
// Meta: response.Meta{
// Page: query.Page,
// Limit: query.Limit,
// TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
// TotalResults: totalResults,
// },
// Data: dto.ToChickinListDTOs(result),
// })
// }
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all chickins successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToChickinListDTOs(result),
})
}
// func (u *ChickinController) GetOne(c *fiber.Ctx) error {
// param := c.Params("id")
@@ -15,7 +15,7 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService
route := v1.Group("/chickins")
route.Use(m.Auth(u))
// route.Get("/", ctrl.GetAll)
route.Get("/", m.RequirePermissions(m.P_ChickinsGetAll), ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne)
// route.Patch("/:id", ctrl.UpdateOne)
@@ -77,6 +77,8 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error {
"Z": 22,
"AA": 16,
"AB": 18,
"AC": 24,
"AD": 18,
}
for col, width := range columnWidths {
@@ -96,7 +98,7 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error {
}
func setRecordingExportHeaders(file *excelize.File, sheet string) error {
verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB"}
verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB", "AC", "AD"}
for _, col := range verticalHeaderCols {
if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil {
return err
@@ -117,6 +119,8 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error {
"Z1": "Catatan Approval",
"AA1": "Dibuat Oleh",
"AB1": "Tanggal Submit",
"AC1": "Nama Sapronak",
"AD1": "Jumlah Input Sapronak",
}
for cell, value := range headerValues {
if err := file.SetCellValue(sheet, cell, value); err != nil {
@@ -230,7 +234,7 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error {
return err
}
return file.SetCellStyle(sheet, "A1", "AB2", headerStyle)
return file.SetCellStyle(sheet, "A1", "AD2", headerStyle)
}
func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error {
@@ -241,11 +245,14 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
columns := []string{
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB",
"AC", "AD",
}
for i, item := range items {
rowNumber := i + 3
currentRow := 3
type rowRange struct{ start, end int }
itemRanges := make([]rowRange, 0, len(items))
for i, item := range items {
fcrStd := 0.0
if item.ProjectFlock.Fcr != nil {
fcrStd = item.ProjectFlock.Fcr.FcrStd
@@ -283,46 +290,79 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
createdBy = safeExportText(item.Approval.ActionBy.Name)
}
// Expand recordings into one row per sapronak
type sapronakRow struct {
name string
input string
}
sapronaks := make([]sapronakRow, 0)
if len(item.FeedUsage) > 0 {
for _, fu := range item.FeedUsage {
sapronaks = append(sapronaks, sapronakRow{
name: safeExportText(fu.ProductName),
input: formatNumberID(fu.UsageAmount+fu.PendingQty, 2, true),
})
}
} else {
sapronaks = append(sapronaks, sapronakRow{name: "-", input: "-"})
}
groupStart := currentRow
for sIdx, s := range sapronaks {
if sIdx == 0 {
rowValues := []interface{}{
i + 1,
locationName,
safeExportText(item.ProjectFlock.FlockName),
kandangName,
item.ProjectFlock.Period,
formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory),
formatAgeLabel(item),
formatDateIndonesian(item.RecordDatetime),
formatNumberID(item.ProjectFlock.TotalChickQty, 0, false),
formatNumberID(item.FcrValue, 2, true),
formatNumberID(fcrStd, 2, true),
formatNumberID(item.FeedIntake, 2, true),
formatNumberID(feedIntakeStd, 2, true),
formatPercentID(item.CumDepletionRate, 2),
formatPercentID(maxDepletionStd, 2),
formatNumberID(item.TotalDepletionQty, 2, true),
formatNumberID(item.EggMass, 2, true),
formatNumberID(eggMassStd, 2, true),
formatNumberID(item.EggWeight, 2, true),
formatNumberID(eggWeightStd, 2, true),
formatPercentID(item.HenDay, 2),
formatPercentID(henDayStd, 2),
formatPercentID(item.HenHouse, 2),
formatPercentID(henHouseStd, 2),
formatApprovalStatus(item),
safeExportText(pointerString(item.Approval.Notes)),
createdBy,
formatDateIndonesian(item.CreatedAt),
i + 1, // A
locationName, // B
safeExportText(item.ProjectFlock.FlockName), // C
kandangName, // D
item.ProjectFlock.Period, // E
formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), // F
formatAgeLabel(item), // G
formatDateIndonesian(item.RecordDatetime), // H
formatNumberID(item.ProjectFlock.TotalChickQty, 0, false), // I
formatNumberID(item.FcrValue, 2, true), // J
formatNumberID(fcrStd, 2, true), // K
formatNumberID(item.FeedIntake, 2, true), // L
formatNumberID(feedIntakeStd, 2, true), // M
formatPercentID(item.CumDepletionRate, 2), // N
formatPercentID(maxDepletionStd, 2), // O
formatNumberID(item.TotalDepletionQty, 2, true), // P
formatNumberID(item.EggMass, 2, true), // Q
formatNumberID(eggMassStd, 2, true), // R
formatNumberID(item.EggWeight, 2, true), // S
formatNumberID(eggWeightStd, 2, true), // T
formatPercentID(item.HenDay, 2), // U
formatPercentID(henDayStd, 2), // V
formatPercentID(item.HenHouse, 2), // W
formatPercentID(henHouseStd, 2), // X
formatApprovalStatus(item), // Y
safeExportText(pointerString(item.Approval.Notes)), // Z
createdBy, // AA
formatDateIndonesian(item.CreatedAt), // AB
s.name, // AC
s.input, // AD
}
for idx, col := range columns {
cell := fmt.Sprintf("%s%d", col, rowNumber)
cell := fmt.Sprintf("%s%d", col, currentRow)
if err := file.SetCellValue(sheet, cell, rowValues[idx]); err != nil {
return err
}
}
} else {
file.SetCellValue(sheet, fmt.Sprintf("AC%d", currentRow), s.name)
file.SetCellValue(sheet, fmt.Sprintf("AD%d", currentRow), s.input)
}
lastRow := len(items) + 2
currentRow++
}
itemRanges = append(itemRanges, rowRange{groupStart, currentRow - 1})
}
lastRow := currentRow - 1
dataCenterStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "center",
@@ -339,7 +379,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AB%d", lastRow), dataCenterStyle); err != nil {
if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AD%d", lastRow), dataCenterStyle); err != nil {
return err
}
@@ -360,13 +400,62 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
return err
}
leftColumns := []string{"B", "C", "D", "F", "G", "H", "Y", "Z", "AA", "AB"}
leftColumns := []string{"B", "C", "D", "F", "G", "H", "Y", "Z", "AA", "AB", "AC"}
for _, col := range leftColumns {
if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), dataLeftStyle); err != nil {
return err
}
}
// Apply bottom border on the last sapronak row of each recording group
// Separate styles to preserve alignment (AC=left, AD=center) and thin borders
borderBottomLeftStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
WrapText: true,
},
Border: []excelize.Border{
{Type: "left", Color: "E6E6E6", Style: 1},
{Type: "top", Color: "E6E6E6", Style: 1},
{Type: "bottom", Color: "999999", Style: 2},
{Type: "right", Color: "E6E6E6", Style: 1},
},
})
if err != nil {
return err
}
borderBottomCenterStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
Border: []excelize.Border{
{Type: "left", Color: "E6E6E6", Style: 1},
{Type: "top", Color: "E6E6E6", Style: 1},
{Type: "bottom", Color: "999999", Style: 2},
{Type: "right", Color: "E6E6E6", Style: 1},
},
})
if err != nil {
return err
}
mergeCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB",
}
for _, rng := range itemRanges {
if rng.end > rng.start {
for _, col := range mergeCols {
file.MergeCell(sheet, fmt.Sprintf("%s%d", col, rng.start), fmt.Sprintf("%s%d", col, rng.end))
}
}
file.SetCellStyle(sheet, fmt.Sprintf("AC%d", rng.end), fmt.Sprintf("AC%d", rng.end), borderBottomLeftStyle)
file.SetCellStyle(sheet, fmt.Sprintf("AD%d", rng.end), fmt.Sprintf("AD%d", rng.end), borderBottomCenterStyle)
}
return nil
}
@@ -74,6 +74,8 @@ type RecordingRelationDTO struct {
ProjectFlock RecordingProjectFlockDTO `json:"project_flock"`
RecordDatetime time.Time `json:"record_datetime"`
Day int `json:"day"`
Week int `json:"week"`
ExcessDays int `json:"excess_days"`
TotalDepletionQty float64 `json:"total_depletion_qty"`
TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"`
CumDepletionRate float64 `json:"cum_depletion_rate"`
@@ -92,6 +94,12 @@ type RecordingRelationDTO struct {
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type RecordingFeedUsageDTO struct {
ProductName string `json:"product_name"`
UsageAmount float64 `json:"usage_amount"`
PendingQty float64 `json:"pending_qty"`
}
type RecordingListDTO struct {
RecordingRelationDTO
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
@@ -99,6 +107,7 @@ type RecordingListDTO struct {
UpdatedAt time.Time `json:"updated_at"`
Kandang *RecordingKandangDTO `json:"kandang,omitempty"`
Location *RecordingLocationDTO `json:"location,omitempty"`
FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"`
}
type RecordingDetailDTO struct {
@@ -192,6 +201,36 @@ func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO {
return result
}
func ToRecordingFeedUsageDTOs(stocks []entity.RecordingStock) []RecordingFeedUsageDTO {
return toRecordingFeedUsageDTOs(stocks)
}
func toRecordingFeedUsageDTOs(stocks []entity.RecordingStock) []RecordingFeedUsageDTO {
result := make([]RecordingFeedUsageDTO, 0, len(stocks))
for _, s := range stocks {
productName := ""
if s.ProductWarehouse.Product.Id != 0 {
productName = s.ProductWarehouse.Product.Name
}
var usageAmount float64
if s.UsageQty != nil {
usageAmount = *s.UsageQty
}
var pendingQty float64
if s.PendingQty != nil {
pendingQty = *s.PendingQty
}
result = append(result, RecordingFeedUsageDTO{
ProductName: productName,
UsageAmount: usageAmount,
PendingQty: pendingQty,
})
}
return result
}
func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO {
result := make([]RecordingEggDTO, len(eggs))
for i, egg := range eggs {
@@ -222,6 +261,7 @@ func toRecordingListDTO(e entity.Recording) RecordingListDTO {
CreatedUser: createdUser,
Kandang: recordingKandangDTO(e),
Location: recordingKandangLocationDTO(e),
FeedUsage: toRecordingFeedUsageDTOs(e.Stocks),
}
}
@@ -232,11 +272,15 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
latestApproval = snapshot
}
day := intValue(e.Day)
return RecordingRelationDTO{
Id: e.Id,
ProjectFlock: toRecordingProjectFlockDTO(e),
RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day),
Day: day,
Week: day / 7,
ExcessDays: day % 7,
TotalDepletionQty: floatValue(e.TotalDepletionQty),
TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty),
CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2),
@@ -276,9 +320,13 @@ func toRecordingProjectFlockDTO(e entity.Recording) RecordingProjectFlockDTO {
result.Period = pfk.Period
if pfk.ProjectFlock.ProductionStandard.Id != 0 {
week := recordingWeekValue(e)
if e.StandardWeek != nil && *e.StandardWeek > 0 {
week = *e.StandardWeek
}
result.ProductionStandart = &RecordingProductionStandardDTO{
Id: pfk.ProjectFlock.ProductionStandard.Id,
Week: recordingWeekValue(e),
Week: week,
Name: pfk.ProjectFlock.ProductionStandard.Name,
HenDayStd: floatValue(e.StandardHenDay),
HenHouseStd: floatValue(e.StandardHenHouse),
@@ -49,6 +49,7 @@ type RecordingRepository interface {
DeleteEggs(tx *gorm.DB, recordingID uint) error
ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error)
UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error
UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error
GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error)
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
@@ -146,7 +147,10 @@ func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.Kandang").
Preload("ProjectFlockKandang.Kandang.Location").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard")
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard").
Preload("Stocks").
Preload("Stocks.ProductWarehouse").
Preload("Stocks.ProductWarehouse.Product")
}
func (r *RecordingRepositoryImpl) latestApprovalSubQuery(db *gorm.DB) *gorm.DB {
@@ -543,6 +547,12 @@ func (r *RecordingRepositoryImpl) UpdateEggTotalQty(tx *gorm.DB, eggID uint, tot
Update("total_qty", totalQty).Error
}
func (r *RecordingRepositoryImpl) UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error {
return tx.Model(&entity.RecordingEgg{}).
Where("id = ?", eggID).
Update("weight", weight).Error
}
func (r *RecordingRepositoryImpl) GetRecordingEggByID(
ctx context.Context,
id uint,
@@ -173,6 +173,37 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return nil, 0, err
}
// Pre-fetch transfer maps by category to avoid N+1 per-recording queries.
growingPFKIDs := make([]uint, 0, len(pfkIDs))
layingPFKIDs := make([]uint, 0, len(pfkIDs))
seenCat := make(map[uint]bool, len(pfkIDs))
for i := range recordings {
pfkID := recordings[i].ProjectFlockKandangId
if pfkID == 0 || seenCat[pfkID] {
continue
}
seenCat[pfkID] = true
cat := ""
if recordings[i].ProjectFlockKandang != nil && recordings[i].ProjectFlockKandang.ProjectFlock.Id != 0 {
cat = strings.ToUpper(strings.TrimSpace(recordings[i].ProjectFlockKandang.ProjectFlock.Category))
}
switch cat {
case string(utils.ProjectFlockCategoryGrowing):
growingPFKIDs = append(growingPFKIDs, pfkID)
case string(utils.ProjectFlockCategoryLaying):
layingPFKIDs = append(layingPFKIDs, pfkID)
}
}
sourceTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandangs(c.Context(), growingPFKIDs)
if err != nil {
return nil, 0, err
}
targetTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandangs(c.Context(), layingPFKIDs)
if err != nil {
return nil, 0, err
}
hasTargetRecordingCache := make(map[uint]bool)
cutOverChickinAvailability := make(map[uint]bool)
for i := range recordings {
if recordings[i].ProjectFlockKandangId != 0 && !recordings[i].RecordDatetime.IsZero() {
@@ -192,7 +223,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
rate := recordingutil.ComputeDepletionRate(prev, current, totalChick)
recordings[i].DepletionRate = &rate
populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i])
populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationStateFromCaches(c.Context(), &recordings[i], sourceTransferByPFK, targetTransferByPFK, hasTargetRecordingCache)
if stateErr != nil {
return nil, 0, stateErr
}
@@ -768,6 +799,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
match := recordingutil.EggTotalsEqual(existingTotals, incomingTotals)
if match {
hasEggChanges = false
} else if recordingutil.EggQtyByWarehouseEqual(existingTotals, incomingTotals) {
// Weight-only change: update weight fields directly without touching FIFO
if err := s.updateEggWeightsOnly(tx, existingEggs, req.Eggs); err != nil {
return err
}
// hasEggChanges stays true so metrics are recomputed
} else {
category := ""
if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 {
@@ -785,7 +822,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil {
return err
}
if err := ensureRecordingEggsUnused(existingEggs); err != nil {
if err := ensureRecordingEggQtyChangeSafe(existingEggs, req.Eggs); err != nil {
return err
}
if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil {
@@ -1308,6 +1345,82 @@ func (s *recordingService) evaluatePopulationMutationState(ctx context.Context,
return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil
}
// evaluatePopulationMutationStateFromCaches is identical to evaluatePopulationMutationState
// but uses pre-fetched transfer maps to avoid N+1 queries in list endpoints.
func (s *recordingService) evaluatePopulationMutationStateFromCaches(
ctx context.Context,
recording *entity.Recording,
sourceTransferByPFK map[uint]*entity.LayingTransfer,
targetTransferByPFK map[uint]*entity.LayingTransfer,
hasTargetRecordingCache map[uint]bool,
) (bool, bool, bool, bool, *entity.LayingTransfer, time.Time, error) {
if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil {
return true, false, false, false, nil, time.Time{}, nil
}
category, err := s.resolveRecordingCategory(ctx, recording)
if err != nil {
s.Log.Errorf("Failed to resolve recording category for population mutation check (recording=%d): %+v", recording.Id, err)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
var transfer *entity.LayingTransfer
switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer = sourceTransferByPFK[recording.ProjectFlockKandangId]
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer = targetTransferByPFK[recording.ProjectFlockKandangId]
default:
return true, false, false, false, nil, time.Time{}, nil
}
if transfer == nil {
return true, false, false, false, nil, time.Time{}, nil
}
transferDate := transferPhysicalMoveDate(transfer)
if transferDate.IsZero() {
return true, false, false, false, transfer, transferDate, nil
}
transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero()
recordDate := normalizeDateOnlyUTC(recording.RecordDatetime)
_, economicCutoffDate := transferRecordingWindow(transfer)
isTransition := !recordDate.Before(transferDate) && recordDate.Before(economicCutoffDate)
isLaying := !recordDate.Before(economicCutoffDate)
populationCanChange := true
if category == strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) {
populationCanChange = !(transferExecuted && !recordDate.Before(transferDate))
if transferExecuted && !recordDate.Before(transferDate) {
var hasTargetLayingRecording bool
if cached, ok := hasTargetRecordingCache[transfer.Id]; ok {
hasTargetLayingRecording = cached
} else {
hasTargetLayingRecording, err = s.hasAnyRecordingOnTransferTargets(ctx, transfer)
if err != nil {
s.Log.Errorf("Failed to resolve target laying recording state for transfer %d: %+v", transfer.Id, err)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi status transisi recording")
}
hasTargetRecordingCache[transfer.Id] = hasTargetLayingRecording
}
if hasTargetLayingRecording {
isTransition = false
isLaying = true
} else {
today := normalizeDateOnlyUTC(time.Now().UTC())
if !today.Before(economicCutoffDate) {
isTransition = true
isLaying = false
}
}
}
}
return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil
}
func (s *recordingService) hasAnyRecordingOnTransferTargets(ctx context.Context, transfer *entity.LayingTransfer) (bool, error) {
if transfer == nil || transfer.Id == 0 {
return false, nil
@@ -2321,21 +2434,24 @@ func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlock
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi")
}
// If this PFK is a laying transfer target, use source growing PFK's chick_in_date
sourcePFKIDs := s.getLayingTransferSourcePFKIDs(ctx, projectFlockKandangID)
var chickinDate time.Time
for _, pop := range populations {
if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() {
continue
}
if chickinDate.IsZero() || pop.ProjectChickin.ChickInDate.Before(chickinDate) {
chickinDate = pop.ProjectChickin.ChickInDate
if len(sourcePFKIDs) > 0 {
for _, pfkID := range sourcePFKIDs {
cd := s.getEarliestChickInDate(ctx, pfkID)
if !cd.IsZero() && (chickinDate.IsZero() || cd.Before(chickinDate)) {
chickinDate = cd
}
}
}
// Fallback: use current PFK's own chick_in_date (cut-over or non-laying)
if chickinDate.IsZero() {
chickinDate = s.getEarliestChickInDate(ctx, projectFlockKandangID)
}
if chickinDate.IsZero() {
return 0, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in tidak ditemukan")
}
@@ -2347,7 +2463,56 @@ func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlock
return 0, fiber.NewError(fiber.StatusBadRequest, "Record date tidak boleh sebelum tanggal chick in")
}
return diff + 1, nil
return diff, nil
}
func (s *recordingService) getLayingTransferSourcePFKIDs(ctx context.Context, targetPFKID uint) []uint {
transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, targetPFKID)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to check laying transfer for pfk=%d: %+v", targetPFKID, err)
}
return nil
}
if transfer == nil {
return nil
}
ids := make([]uint, 0)
if transfer.SourceProjectFlockKandangId != nil {
ids = append(ids, *transfer.SourceProjectFlockKandangId)
}
// Check multi-source transfers
var sources []entity.LayingTransferSource
if err := s.Repository.DB().WithContext(ctx).
Where("laying_transfer_id = ?", transfer.Id).
Find(&sources).Error; err == nil {
for _, src := range sources {
ids = append(ids, src.SourceProjectFlockKandangId)
}
}
return ids
}
func (s *recordingService) getEarliestChickInDate(ctx context.Context, pfkID uint) time.Time {
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, pfkID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for pfk=%d: %+v", pfkID, err)
return time.Time{}
}
var chickinDate time.Time
for _, pop := range populations {
if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() {
continue
}
if chickinDate.IsZero() || pop.ProjectChickin.ChickInDate.Before(chickinDate) {
chickinDate = pop.ProjectChickin.ChickInDate
}
}
return chickinDate
}
func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error {
@@ -2508,8 +2673,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
if isGrowing {
week := 0
if recording.Day != nil && *recording.Day > 0 {
week = (*recording.Day-1)/7 + 1
if recording.Day != nil && *recording.Day >= 0 {
week = *recording.Day/7 + 1
}
if week > 0 && s.Repository != nil {
meanBw, ok, err := s.Repository.GetUniformityMeanBwByWeek(tx, recording.ProjectFlockKandangId, week)
@@ -3008,6 +3173,12 @@ func (s *recordingService) reflowSyncRecordingStocks(
existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock)
}
shouldWriteLog := shouldWriteRecordingStockLog(note, actorID)
if shouldWriteLog && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
resetLogState := newRecordingStockLogState()
stocksToApply := make([]entity.RecordingStock, 0, len(incoming))
for _, item := range incoming {
list := existingByWarehouse[item.ProductWarehouseId]
@@ -3015,6 +3186,25 @@ func (s *recordingService) reflowSyncRecordingStocks(
if len(list) > 0 {
stock = list[0]
existingByWarehouse[item.ProductWarehouseId] = list[1:]
// Write reset (increase) stock_log for the OLD consumption BEFORE overwriting UsageQty.
// FIFO internally does Rollback+Reallocate inside reflowApplyRecordingStocks, but the
// corresponding +increase stock_log for the rollback step was previously missing, causing
// stock_log.stock to drift below the true FIFO qty on every in-place edit.
rollbackQty := recordingStockRollbackQty(stock)
if rollbackQty > 1e-6 && shouldWriteLog {
resetLog := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Increase: rollbackQty,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
}
if err := s.appendRecordingStockLog(ctx, tx, resetLogState, resetLog); err != nil {
return err
}
}
} else {
zero := 0.0
stock = entity.RecordingStock{
@@ -3672,6 +3862,44 @@ func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
return nil
}
func ensureRecordingEggQtyChangeSafe(existingEggs []entity.RecordingEgg, reqEggs []validation.Egg) error {
usedByWarehouse := make(map[uint]float64)
for _, egg := range existingEggs {
usedByWarehouse[egg.ProductWarehouseId] += egg.TotalUsed
}
newQtyByWarehouse := make(map[uint]int)
for _, egg := range reqEggs {
newQtyByWarehouse[egg.ProductWarehouseId] += egg.Qty
}
for warehouseID, used := range usedByWarehouse {
if used <= 0 {
continue
}
if float64(newQtyByWarehouse[warehouseID]) < used {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Jumlah telur tidak dapat dikurangi di bawah jumlah yang sudah terjual (%.0f butir)", used))
}
}
return nil
}
func (s *recordingService) updateEggWeightsOnly(tx *gorm.DB, existingEggs []entity.RecordingEgg, reqEggs []validation.Egg) error {
weightByWarehouse := make(map[uint]*float64)
for i := range reqEggs {
weightByWarehouse[reqEggs[i].ProductWarehouseId] = reqEggs[i].Weight
}
for _, egg := range existingEggs {
newWeight, ok := weightByWarehouse[egg.ProductWarehouseId]
if !ok {
continue
}
if err := s.Repository.UpdateEggWeight(tx, egg.Id, newWeight); err != nil {
return err
}
}
return nil
}
func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error {
if tx == nil || projectFlockKandangId == 0 || from.IsZero() {
return nil
@@ -17,6 +17,8 @@ type TransferLayingRepository interface {
IdExists(ctx context.Context, id uint) (bool, error)
GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error)
GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error)
GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error)
GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error)
// Tambah method baru untuk query dengan filter lengkap
GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error)
@@ -242,3 +244,121 @@ func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandang(ctx cont
}
return &transfer, nil
}
type pfkTransferIDRow struct {
SourcePFKID uint `gorm:"column:source_pfk_id"`
TransferID uint `gorm:"column:transfer_id"`
}
func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) {
result := make(map[uint]*entity.LayingTransfer)
if len(pfkIDs) == 0 {
return result, nil
}
var rows []pfkTransferIDRow
err := r.db.WithContext(ctx).Raw(`
SELECT DISTINCT ON (source_pfk_id) source_pfk_id, transfer_id
FROM (
SELECT id AS transfer_id, source_project_flock_kandang_id AS source_pfk_id
FROM laying_transfers
WHERE source_project_flock_kandang_id IN ?
AND deleted_at IS NULL
AND (
SELECT a.action FROM approvals a
WHERE a.approvable_type = ? AND a.approvable_id = id
ORDER BY a.id DESC LIMIT 1
) = ?
UNION ALL
SELECT lts.laying_transfer_id AS transfer_id, lts.source_project_flock_kandang_id AS source_pfk_id
FROM laying_transfer_sources lts
JOIN laying_transfers t ON t.id = lts.laying_transfer_id AND t.deleted_at IS NULL
WHERE lts.source_project_flock_kandang_id IN ?
AND lts.deleted_at IS NULL
AND (
SELECT a.action FROM approvals a
WHERE a.approvable_type = ? AND a.approvable_id = t.id
ORDER BY a.id DESC LIMIT 1
) = ?
) combined
ORDER BY source_pfk_id, transfer_id DESC
`,
pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved),
pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved),
).Scan(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return result, nil
}
transferIDs := make([]uint, 0, len(rows))
pfkByTransfer := make(map[uint]uint, len(rows))
for _, row := range rows {
transferIDs = append(transferIDs, row.TransferID)
pfkByTransfer[row.TransferID] = row.SourcePFKID
}
var transfers []entity.LayingTransfer
if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Find(&transfers).Error; err != nil {
return nil, err
}
for i := range transfers {
if pfkID := pfkByTransfer[transfers[i].Id]; pfkID != 0 {
result[pfkID] = &transfers[i]
}
}
return result, nil
}
func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) {
result := make(map[uint]*entity.LayingTransfer)
if len(pfkIDs) == 0 {
return result, nil
}
var rows []pfkTransferIDRow
err := r.db.WithContext(ctx).Raw(`
SELECT DISTINCT ON (source_pfk_id) source_pfk_id, transfer_id
FROM (
SELECT ltt.laying_transfer_id AS transfer_id, ltt.target_project_flock_kandang_id AS source_pfk_id
FROM laying_transfer_targets ltt
JOIN laying_transfers t ON t.id = ltt.laying_transfer_id AND t.deleted_at IS NULL
WHERE ltt.target_project_flock_kandang_id IN ?
AND ltt.deleted_at IS NULL
AND (
SELECT a.action FROM approvals a
WHERE a.approvable_type = ? AND a.approvable_id = t.id
ORDER BY a.id DESC LIMIT 1
) = ?
) combined
ORDER BY source_pfk_id, transfer_id DESC
`,
pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved),
).Scan(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return result, nil
}
transferIDs := make([]uint, 0, len(rows))
pfkByTransfer := make(map[uint]uint, len(rows))
for _, row := range rows {
transferIDs = append(transferIDs, row.TransferID)
pfkByTransfer[row.TransferID] = row.SourcePFKID
}
var transfers []entity.LayingTransfer
if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Find(&transfers).Error; err != nil {
return nil, err
}
for i := range transfers {
if pfkID := pfkByTransfer[transfers[i].Id]; pfkID != 0 {
result[pfkID] = &transfers[i]
}
}
return result, nil
}
@@ -91,17 +91,17 @@ func buildPurchaseQuery(c *fiber.Ctx) *validation.Query {
Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
PoDate: strings.TrimSpace(c.Query("po_date")),
PoDateFrom: strings.TrimSpace(c.Query("po_date_from")),
PoDateTo: strings.TrimSpace(c.Query("po_date_to")),
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
CreatedTo: strings.TrimSpace(c.Query("created_to")),
StartDate: strings.TrimSpace(c.Query("start_date")),
EndDate: strings.TrimSpace(c.Query("end_date")),
FilterBy: strings.TrimSpace(c.Query("filter_by")),
SupplierID: uint(c.QueryInt("supplier_id", 0)),
AreaID: uint(c.QueryInt("area_id", 0)),
LocationID: uint(c.QueryInt("location_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")),
SortBy: strings.TrimSpace(c.Query("sort_by", "")),
SortOrder: strings.TrimSpace(c.Query("sort_order", "")),
}
}
@@ -2,14 +2,11 @@ package controller
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
@@ -45,16 +42,13 @@ func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) {
}
}
listItems := dto.ToPurchaseListDTOs(purchases)
grandTotals := buildPurchaseGrandTotalMap(purchases)
if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil {
return nil, err
}
if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil {
return nil, err
}
if err := setPurchaseExportRows(file, purchaseExportSheetName, listItems, grandTotals); err != nil {
if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases); err != nil {
return nil, err
}
if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{
@@ -81,10 +75,19 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
"D": 14,
"E": 22,
"F": 22,
"G": 18,
"H": 18,
"I": 52,
"J": 24,
"G": 22,
"H": 32,
"I": 10,
"J": 12,
"K": 16,
"L": 16,
"M": 22,
"N": 12,
"O": 16,
"P": 16,
"Q": 18,
"R": 18,
"S": 24,
}
for col, width := range columnWidths {
@@ -101,16 +104,25 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{
"PR Number",
"PO Number",
"Tanggal PO",
"Tanggal Terima",
"Supplier",
"Lokasi",
"Status",
"Grand Total",
"Products",
"Notes",
"PR Number", // A
"PO Number", // B
"Tanggal PO", // C
"Tanggal Terima", // D
"Supplier", // E
"Lokasi", // F
"Gudang", // G
"Product", // H
"Qty", // I
"Satuan", // J
"Price", // K
"Total Produk", // L
"Vendor Ekspedisi",// M
"Qty Ekspedisi", // N
"Price Ekspedisi", // O
"Total Ekspedisi", // P
"Grand Total All", // Q
"Status", // R
"Notes", // S
}
for i, header := range headers {
@@ -138,49 +150,36 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
return err
}
return file.SetCellStyle(sheet, "A1", "J1", headerStyle)
return file.SetCellStyle(sheet, "A1", "S1", headerStyle)
}
func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.PurchaseListDTO, grandTotals map[uint]float64) error {
if len(items) == 0 {
func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase) error {
if len(purchases) == 0 {
return nil
}
for i, item := range items {
row := strconv.Itoa(i + 2)
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(item.PrNumber)); err != nil {
var sumL, sumP, sumQ float64
rowIdx := 2
for p := range purchases {
purchase := &purchases[p]
if len(purchase.Items) == 0 {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, &sumL, &sumP, &sumQ); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(item.PoNumber)); err != nil {
rowIdx++
continue
}
for it := range purchase.Items {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], &sumL, &sumP, &sumQ); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(item.PoDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+row, safePurchaseSupplierName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+row, safePurchaseLocationName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+row, formatPurchaseExportStatus(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+row, formatPurchaseRupiah(grandTotals[item.Id])); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+row, formatPurchaseProducts(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+row, safePurchaseExportPointerText(item.Notes)); err != nil {
return err
rowIdx++
}
}
lastRow := len(items) + 1
lastDataRow := rowIdx - 1
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
@@ -197,7 +196,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A2", "J"+strconv.Itoa(lastRow), dataStyle); err != nil {
if err := file.SetCellStyle(sheet, "A2", "S"+strconv.Itoa(lastDataRow), dataStyle); err != nil {
return err
}
@@ -216,47 +215,239 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha
if err != nil {
return err
}
return file.SetCellStyle(sheet, "H2", "H"+strconv.Itoa(lastRow), moneyStyle)
if err := file.SetCellStyle(sheet, "K2", "Q"+strconv.Itoa(lastDataRow), moneyStyle); err != nil {
return err
}
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 {
result := make(map[uint]float64, len(items))
for i := range items {
total := 0.0
for j := range items[i].Items {
total += items[i].Items[j].TotalPrice
}
result[items[i].Id] = total
}
return result
return addPurchaseExportSumRow(file, sheet, rowIdx, sumL, sumP, sumQ)
}
func safePurchaseSupplierName(item dto.PurchaseListDTO) string {
if item.Supplier == nil {
func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, sumL, sumP, sumQ *float64) error {
row := strconv.Itoa(rowIdx)
// Purchase-level columns (repeat for every item row of the same purchase)
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(purchase.PrNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(purchase.PoNumber)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(purchase.PoDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+row, safePurchaseExportEntitySupplierName(purchase)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "R"+row, formatPurchaseExportEntityStatus(purchase)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "S"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil {
return err
}
if item == nil {
for _, col := range []string{"D", "F", "G", "H", "J", "M"} {
if err := file.SetCellValue(sheet, col+row, "-"); err != nil {
return err
}
}
for _, col := range []string{"I", "K", "L", "N", "O", "P", "Q"} {
if err := file.SetCellValue(sheet, col+row, 0); err != nil {
return err
}
}
return nil
}
// Item-level columns
var expeditionQty, expeditionPrice, expeditionTotal float64
if item.ExpenseNonstock != nil {
expeditionQty = item.ExpenseNonstock.Qty
expeditionPrice = item.ExpenseNonstock.Price
expeditionTotal = expeditionQty * expeditionPrice
}
itemGrandTotal := item.TotalPrice + expeditionTotal
*sumL += item.TotalPrice
*sumP += expeditionTotal
*sumQ += itemGrandTotal
if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+row, safePurchaseItemLocationName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+row, safePurchaseWarehouseName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+row, safePurchaseItemProductName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+row, item.TotalQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+row, safePurchaseItemUomName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+row, item.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+row, item.TotalPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+row, safePurchaseItemExpeditionVendorName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+row, expeditionQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+row, expeditionPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+row, expeditionTotal); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+row, itemGrandTotal); err != nil {
return err
}
return nil
}
func addPurchaseExportSumRow(file *excelize.File, sheet string, rowIdx int, sumL, sumP, sumQ float64) error {
row := strconv.Itoa(rowIdx)
sumStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FEF3C7"}},
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 2},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
sumMoneyStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FEF3C7"}},
Alignment: &excelize.Alignment{
Horizontal: "right",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 2},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+row, "S"+row, sumStyle); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "L"+row, "L"+row, sumMoneyStyle); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "P"+row, "Q"+row, sumMoneyStyle); err != nil {
return err
}
if err := file.SetCellValue(sheet, "A"+row, "TOTAL"); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+row, sumL); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+row, sumP); err != nil {
return err
}
return file.SetCellValue(sheet, "Q"+row, sumQ)
}
func safePurchaseExportEntitySupplierName(purchase *entity.Purchase) string {
if purchase.Supplier.Id == 0 {
return "-"
}
return safePurchaseExportText(item.Supplier.Name)
return safePurchaseExportText(purchase.Supplier.Name)
}
func safePurchaseLocationName(item dto.PurchaseListDTO) string {
if item.Location == nil {
func safePurchaseWarehouseName(item *entity.PurchaseItem) string {
if item.Warehouse == nil {
return "-"
}
return safePurchaseExportText(item.Location.Name)
return safePurchaseExportText(item.Warehouse.Name)
}
func formatPurchaseExportStatus(item dto.PurchaseListDTO) string {
if item.LatestApproval == nil {
func safePurchaseItemLocationName(item *entity.PurchaseItem) string {
if item.Warehouse == nil || item.Warehouse.Location == nil {
return "-"
}
return safePurchaseExportText(item.Warehouse.Location.Name)
}
func safePurchaseItemProductName(item *entity.PurchaseItem) string {
if item.Product == nil {
return "-"
}
return safePurchaseExportText(item.Product.Name)
}
func safePurchaseItemUomName(item *entity.PurchaseItem) string {
if item.Product == nil || item.Product.Uom.Id == 0 {
return "-"
}
return safePurchaseExportText(item.Product.Uom.Name)
}
func safePurchaseItemExpeditionVendorName(item *entity.PurchaseItem) string {
if item.ExpenseNonstock == nil || item.ExpenseNonstock.Expense == nil {
return "-"
}
exp := item.ExpenseNonstock.Expense
if exp.Supplier == nil || exp.Supplier.Id == 0 {
return "-"
}
return safePurchaseExportText(exp.Supplier.Name)
}
func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string {
if purchase.LatestApproval == nil {
return "-"
}
if item.LatestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
if purchase.LatestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(string(*purchase.LatestApproval.Action)), string(entity.ApprovalActionRejected)) {
return "Ditolak"
}
return safePurchaseExportText(item.LatestApproval.StepName)
return safePurchaseExportText(purchase.LatestApproval.StepName)
}
var purchaseIndonesianMonths = map[time.Month]string{
time.January: "Jan",
time.February: "Feb",
time.March: "Mar",
time.April: "Apr",
time.May: "Mei",
time.June: "Jun",
time.July: "Jul",
time.August: "Ags",
time.September: "Sep",
time.October: "Okt",
time.November: "Nov",
time.December: "Des",
}
func formatPurchaseExportDate(value *time.Time) string {
@@ -270,34 +461,8 @@ func formatPurchaseExportDate(value *time.Time) string {
t = t.In(location)
}
return t.Format("02-01-2006")
}
func formatPurchaseProducts(item dto.PurchaseListDTO) string {
if len(item.Products) == 0 {
return "-"
}
seen := make(map[string]struct{})
names := make([]string, 0, len(item.Products))
for i := range item.Products {
name := strings.TrimSpace(item.Products[i].Name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return "-"
}
sort.Strings(names)
return strings.Join(names, ", ")
month := purchaseIndonesianMonths[t.Month()]
return fmt.Sprintf("%d-%s-%02d", t.Day(), month, t.Year()%100)
}
func safePurchaseExportPointerText(value *string) string {
@@ -315,37 +480,3 @@ func safePurchaseExportText(value string) string {
return trimmed
}
func formatPurchaseRupiah(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) {
return "Rp 0"
}
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
}
}
for i := rem; i < len(raw); i += 3 {
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
}
@@ -22,9 +22,8 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
nil,
"catatan",
[]entity.PurchaseItem{
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000, "Location A"),
buildPurchaseItemForExportTest(12, "Vitamin A", 350000, "Location B"),
buildPurchaseItemForExportTest(11, "Pakan Starter", 0, ""),
buildPurchaseItemForExportTest(11, "Pakan Starter", 500, 2, 1000000, "Location A", "kg"),
buildPurchaseItemForExportTest(12, "Vitamin A", 350, 1, 350000, "Location B", "botol"),
},
),
buildPurchaseForExportTest(
@@ -37,7 +36,7 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
ptrApprovalAction(entity.ApprovalActionRejected),
"",
[]entity.PurchaseItem{
buildPurchaseItemForExportTest(21, "Obat X", 75000, ""),
buildPurchaseItemForExportTest(21, "Obat X", 75000, 1, 75000, "", ""),
},
),
})
@@ -51,16 +50,27 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
}
defer file.Close()
// Verify all 19 headers
expectedHeaders := map[string]string{
"A1": "PR Number",
"B1": "PO Number",
"C1": "Tanggal PO",
"D1": "Supplier",
"E1": "Lokasi",
"F1": "Status",
"G1": "Grand Total",
"H1": "Products",
"I1": "Notes",
"D1": "Tanggal Terima",
"E1": "Supplier",
"F1": "Lokasi",
"G1": "Gudang",
"H1": "Product",
"I1": "Qty",
"J1": "Satuan",
"K1": "Price",
"L1": "Total Produk",
"M1": "Vendor Ekspedisi",
"N1": "Qty Ekspedisi",
"O1": "Price Ekspedisi",
"P1": "Total Ekspedisi",
"Q1": "Grand Total All",
"R1": "Status",
"S1": "Notes",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(purchaseExportSheetName, cell)
@@ -72,24 +82,46 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
}
}
// Row 2: Purchase 1, Item 1 (Pakan Starter)
assertPurchaseCellEquals(t, file, "A2", "PR-00011")
assertPurchaseCellEquals(t, file, "B2", "PO-00011")
assertPurchaseCellEquals(t, file, "C2", "22-04-2026")
assertPurchaseCellEquals(t, file, "D2", "Supplier A")
assertPurchaseCellEquals(t, file, "E2", "Location A")
assertPurchaseCellEquals(t, file, "F2", "Manager Purchase")
assertPurchaseCellEquals(t, file, "G2", "Rp 1.350.000")
assertPurchaseCellEquals(t, file, "H2", "Pakan Starter, Vitamin A")
assertPurchaseCellEquals(t, file, "I2", "catatan")
assertPurchaseCellEquals(t, file, "E2", "Supplier A")
assertPurchaseCellEquals(t, file, "F2", "Location A")
assertPurchaseCellEquals(t, file, "H2", "Pakan Starter")
assertPurchaseCellEquals(t, file, "J2", "kg")
assertPurchaseCellEquals(t, file, "K2", "500")
assertPurchaseCellEquals(t, file, "L2", "1000000")
assertPurchaseCellEquals(t, file, "M2", "-")
assertPurchaseCellEquals(t, file, "P2", "0")
assertPurchaseCellEquals(t, file, "Q2", "1000000")
assertPurchaseCellEquals(t, file, "R2", "Manager Purchase")
assertPurchaseCellEquals(t, file, "S2", "catatan")
assertPurchaseCellEquals(t, file, "A3", "PR-00012")
assertPurchaseCellEquals(t, file, "B3", "-")
assertPurchaseCellEquals(t, file, "C3", "-")
assertPurchaseCellEquals(t, file, "E3", "-")
assertPurchaseCellEquals(t, file, "F3", "Ditolak")
assertPurchaseCellEquals(t, file, "G3", "Rp 75.000")
assertPurchaseCellEquals(t, file, "H3", "Obat X")
assertPurchaseCellEquals(t, file, "I3", "-")
// Row 3: Purchase 1, Item 2 (Vitamin A)
assertPurchaseCellEquals(t, file, "A3", "PR-00011")
assertPurchaseCellEquals(t, file, "H3", "Vitamin A")
assertPurchaseCellEquals(t, file, "J3", "botol")
assertPurchaseCellEquals(t, file, "L3", "350000")
assertPurchaseCellEquals(t, file, "Q3", "350000")
// Row 4: Purchase 2, Item 1 (Obat X) — no location, rejected
assertPurchaseCellEquals(t, file, "A4", "PR-00012")
assertPurchaseCellEquals(t, file, "B4", "-")
assertPurchaseCellEquals(t, file, "C4", "-")
assertPurchaseCellEquals(t, file, "F4", "-")
assertPurchaseCellEquals(t, file, "H4", "Obat X")
assertPurchaseCellEquals(t, file, "J4", "-")
assertPurchaseCellEquals(t, file, "L4", "75000")
assertPurchaseCellEquals(t, file, "Q4", "75000")
assertPurchaseCellEquals(t, file, "R4", "Ditolak")
assertPurchaseCellEquals(t, file, "S4", "-")
// Row 5: SUM row — total produk=1425000, ekspedisi=0, grand total all=1425000
assertPurchaseCellEquals(t, file, "A5", "TOTAL")
assertPurchaseCellEquals(t, file, "L5", "1425000")
assertPurchaseCellEquals(t, file, "P5", "0")
assertPurchaseCellEquals(t, file, "Q5", "1425000")
}
func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
@@ -144,13 +176,20 @@ func buildPurchaseForExportTest(
}
}
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64, locationName string) entity.PurchaseItem {
func buildPurchaseItemForExportTest(productID uint, productName string, price, totalQty, totalPrice float64, locationName, uomName string) entity.PurchaseItem {
uomID := uint(0)
if uomName != "" {
uomID = productID + 2000
}
item := entity.PurchaseItem{
ProductId: productID,
Price: price,
TotalQty: totalQty,
TotalPrice: totalPrice,
Product: &entity.Product{
Id: productID,
Name: productName,
Uom: entity.Uom{Id: uomID, Name: uomName},
},
}
@@ -31,12 +31,16 @@ type PurchaseListDTO struct {
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
RequesterName string `json:"requester_name"`
PoExpedition []PoExpeditionDTO `json:"po_expedition"`
Items []PurchaseItemDTO `json:"items"`
Products []productDTO.ProductRelationDTO `json:"products"`
Location *locationDTO.LocationRelationDTO `json:"location"`
Area *areaDTO.AreaRelationDTO `json:"area"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
ProductsTotal float64 `json:"products_total"`
ExpeditionTotal float64 `json:"expedition_total"`
GrandTotalAll float64 `json:"grand_total_all"`
}
type PurchaseDetailDTO struct {
@@ -68,6 +72,8 @@ type PurchaseItemDTO struct {
VehicleNumber *string `json:"vehicle_number"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"`
ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"`
ExpeditionQty float64 `json:"expedition_qty"`
ExpeditionTotal float64 `json:"expedition_total"`
HasChickin bool `json:"has_chickin"`
}
@@ -126,6 +132,8 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO {
if item.ExpenseNonstock != nil {
priceCopy := item.ExpenseNonstock.Price
dto.TransportPerItem = &priceCopy
dto.ExpeditionQty = item.ExpenseNonstock.Qty
dto.ExpeditionTotal = item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price
if item.ExpenseNonstock.Expense != nil {
exp := item.ExpenseNonstock.Expense
@@ -176,11 +184,17 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO
receivedDate *time.Time
productsTotal float64
expeditionTotal float64
)
productMap := make(map[uint]productDTO.ProductRelationDTO)
expeditionRefSet := make(map[uint64]struct{})
for i := range p.Items {
item := p.Items[i]
productsTotal += item.TotalPrice
if item.ExpenseNonstock != nil {
expeditionTotal += item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price
}
if item.Product != nil && item.Product.Id != 0 {
if _, exists := productMap[item.Product.Id]; !exists {
productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product)
@@ -227,12 +241,16 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
CreatedUser: createdUser,
RequesterName: requesterName,
PoExpedition: poExpedition,
Items: ToPurchaseItemDTOs(p.Items),
Products: products,
Location: location,
Area: area,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
LatestApproval: latestApproval,
ProductsTotal: productsTotal,
ExpeditionTotal: expeditionTotal,
GrandTotalAll: productsTotal + expeditionTotal,
}
}
@@ -145,33 +145,16 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
offset := (params.Page - 1) * params.Limit
createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo)
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
productCategoryIDs, err := parseUintCSVFilter(params.ProductCategoryID, "product_category_id")
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
var poDateStart *time.Time
var poDateEnd *time.Time
if strings.TrimSpace(params.PoDate) != "" {
poDate, parseErr := utils.ParseDateString(strings.TrimSpace(params.PoDate))
if parseErr != nil {
return nil, 0, utils.BadRequest("po_date must use format YYYY-MM-DD")
}
poDateStart = &poDate
poDateEndValue := poDate.AddDate(0, 0, 1)
poDateEnd = &poDateEndValue
} else {
poDateStart, poDateEnd, err = parsePoDateRangeForQuery(params.PoDateFrom, params.PoDateTo)
dateStart, dateEnd, err := parsePurchaseDateRangeForQuery(params.StartDate, params.EndDate, "date")
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
}
filterBy := strings.TrimSpace(params.FilterBy)
search := strings.ToLower(strings.TrimSpace(params.Search))
approvalStatuses := parseStringCSVFilter(params.ApprovalStatus)
@@ -187,23 +170,41 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = db.Where("supplier_id = ?", params.SupplierID)
}
if createdFrom != nil {
db = db.Where("created_at >= ?", *createdFrom)
switch filterBy {
case "po_date":
if dateStart != nil {
db = db.Where("purchases.po_date >= ?", *dateStart)
}
if createdTo != nil {
db = db.Where("created_at < ?", *createdTo)
if dateEnd != nil {
db = db.Where("purchases.po_date < ?", *dateEnd)
}
if poDateStart != nil {
db = db.Where("purchases.po_date >= ?", *poDateStart)
case "due_date":
if dateStart != nil {
db = db.Where("purchases.due_date >= ?", *dateStart)
}
if poDateStart != nil {
db = db.Where("purchases.po_date >= ?", *poDateStart)
if dateEnd != nil {
db = db.Where("purchases.due_date < ?", *dateEnd)
}
case "received_date":
if dateStart != nil {
db = db.Where(
`EXISTS (SELECT 1 FROM purchase_items pi WHERE pi.purchase_id = purchases.id AND pi.received_date >= ?)`,
*dateStart,
)
}
if dateEnd != nil {
db = db.Where(
`EXISTS (SELECT 1 FROM purchase_items pi WHERE pi.purchase_id = purchases.id AND pi.received_date < ?)`,
*dateEnd,
)
}
default:
if dateStart != nil {
db = db.Where("purchases.created_at >= ?", *dateStart)
}
if dateEnd != nil {
db = db.Where("purchases.created_at < ?", *dateEnd)
}
if poDateEnd != nil {
db = db.Where("purchases.po_date < ?", *poDateEnd)
}
if scope.Restrict {
@@ -261,7 +262,56 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = applyPurchaseApprovalStatusFilter(db, approvalStatuses)
db = applyPurchaseSearchFilter(db, search)
sortBy := strings.TrimSpace(params.SortBy)
sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder))
if sortBy == "" && (filterBy == "po_date" || filterBy == "due_date" || filterBy == "received_date" || filterBy == "created_at") {
sortBy = filterBy
if sortOrder == "" {
sortOrder = "ASC"
}
}
if sortOrder == "" {
sortOrder = "DESC"
}
switch sortBy {
case "po_expedition":
return db.Order(`(SELECT MIN(e.reference_number) FROM purchase_items pi
LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
LEFT JOIN expenses e ON e.id = en.expense_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder + " NULLS LAST")
case "supplier":
return db.Order(`(SELECT COALESCE(s.name, '') FROM suppliers s WHERE s.id = purchases.supplier_id) ` + sortOrder)
case "requester_name":
return db.Order(`(SELECT COALESCE(u.name, '') FROM users u WHERE u.id = purchases.created_by) ` + sortOrder)
case "products":
return db.Order(`(SELECT MIN(COALESCE(p.name, '')) FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder)
case "location":
return db.Order(`(SELECT MIN(COALESCE(l.name, '')) FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
JOIN locations l ON l.id = w.location_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder)
case "po_date":
return db.Order("purchases.po_date " + sortOrder)
case "po_number":
return db.Order("COALESCE(purchases.po_number, purchases.pr_number) " + sortOrder)
case "received_date":
return db.Order(`(SELECT MIN(pi2.received_date) FROM purchase_items pi2 WHERE pi2.purchase_id = purchases.id) ` + sortOrder)
case "due_date":
return db.Order("purchases.due_date " + sortOrder)
case "status":
return db.Order(`(SELECT COALESCE(a.step_name, '') FROM approvals a
WHERE a.approvable_type = 'PURCHASES' AND a.approvable_id = purchases.id
ORDER BY a.action_at DESC, a.id DESC LIMIT 1) ` + sortOrder)
case "created_at":
return db.Order("purchases.created_at " + sortOrder)
default:
return db.Order("created_at DESC").Order("purchases.id DESC")
}
})
if err != nil {
@@ -2197,30 +2247,36 @@ func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []ent
return nil
}
func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) {
func parsePurchaseDateRangeForQuery(fromStr, toStr, fieldName string) (*time.Time, *time.Time, error) {
jakartaLoc, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
jakartaLoc = time.FixedZone("WIB", 7*60*60)
}
var fromPtr *time.Time
var toPtr *time.Time
if strings.TrimSpace(fromStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr))
if err != nil {
return nil, nil, errors.New("po_date_from must use format YYYY-MM-DD")
return nil, nil, errors.New(fieldName + "_from must use format YYYY-MM-DD")
}
fromValue := parsed
fromPtr = &fromValue
t := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, jakartaLoc)
fromPtr = &t
}
if strings.TrimSpace(toStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(toStr))
if err != nil {
return nil, nil, errors.New("po_date_to must use format YYYY-MM-DD")
return nil, nil, errors.New(fieldName + "_to must use format YYYY-MM-DD")
}
nextDay := parsed.AddDate(0, 0, 1)
t := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, jakartaLoc)
nextDay := t.AddDate(0, 0, 1)
toPtr = &nextDay
}
if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) {
return nil, nil, errors.New("po_date_from must be earlier than po_date_to")
return nil, nil, errors.New(fieldName + "_from must be earlier than " + fieldName + "_to")
}
return fromPtr, toPtr, nil
@@ -75,10 +75,10 @@ type Query struct {
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"`
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"`
PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=po_date due_date received_date created_at"`
Search string `query:"search" validate:"omitempty,max=100"`
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"`
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=po_expedition supplier requester_name products location po_date received_date due_date status created_at po_number"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"`
}

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