Compare commits

...

186 Commits

Author SHA1 Message Date
MacBook Air M1 0285852c42 fix api get all closing; fix get closing sapronak; fix get all maste data product 2025-12-30 14:42:53 +07:00
Hafizh A. Y. ddda696454 Merge branch 'fix/BE/US-74-add_production_standart_project_flock' into 'feat/BE/Sprint-8'
feat(BE-74): add production standart to project_flock and implement rbac...

See merge request mbugroup/lti-api!113
2025-12-29 16:22:29 +00:00
ragilap 635049163e feat(BE-74): add production standart to project_flock and implement rbac finance and standart production 2025-12-29 23:15:34 +07:00
Hafizh A. Y. 68703d8752 Merge branch 'dev/teguh' into 'feat/BE/Sprint-8'
feat(BE): expense(adjust expense add option attach to farm and not to kandang ).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

See merge request mbugroup/lti-api!82
2025-12-06 04:34:31 +00:00
ragilap 2fbf66f9f7 add function for read closing project flock kandang and project flock 2025-12-05 22:51:59 +07:00
ragilap 70b2a5a2d1 deleted grade in recording egg unfinished: daily gain question, and confirm counting about fcr, adg, mortality and others 2025-12-05 21:58:51 +07:00
aguhh18 008709c19c Feat[BE-300]: add preload for kandang for get penjualan 2025-12-05 19:08:58 +07:00
ragilap 6572176cca feat/BE/US-33/TASK-292,293,Adjust Project Flock status (add status Selesai), Validate with restriction when expense not finish and stock is not used 2025-12-05 17:47:03 +07:00
giovanni-ce 4c63bd14c3 feat[BE-298]: add api get one closing general information 2025-12-05 17:15:05 +07:00
ragilap c593df661c Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi 2025-12-05 14:09:37 +07:00
ragilap ee2db748ea implement bop for expedition must recheck and qty in staff purchase need info 2025-12-05 14:08:54 +07:00
aguhh18 5afee298b0 FIX[BE]: uncomment middleware usage for delivery and sales orders routes 2025-12-05 13:44:57 +07:00
aguhh18 2bc67a8433 FIX[BE] : fixing deleted create at and create by on product warehouse 2025-12-05 13:35:12 +07:00
aguhh18 b4ccd33ea0 FIX{BE]: fixing product warehouse delete created user on preload 2025-12-05 13:30:36 +07:00
aguhh18 c279303b99 Feat[BE-300]: creating API Get closing penjualan 2025-12-05 12:31:52 +07:00
aguhh18 8c883669d3 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-04 20:11:17 +07:00
ragilap fc14f9a98f uncoment auth middleware 2025-12-04 18:58:20 +07:00
ragilap 17269d701c adjustment create project flock must have a relation for location,area and kandang 2025-12-04 18:54:04 +07:00
ragilap c3305d3089 uncomment auth middleware 2025-12-04 18:45:45 +07:00
ragilap b43e2b44ec deleted edit function in project-flock, and must retest closing feat after fixing product warehouse 2025-12-04 18:44:56 +07:00
ragilap 6e3a8f3551 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi 2025-12-04 18:18:53 +07:00
ragilap 415d5c0e67 Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi 2025-12-04 15:03:22 +07:00
ragilap 1bca29cd31 adjustment recording adding weight in recording egg : need info, deleted grading egg, adjustment validation if must be changed again 2025-12-04 14:55:42 +07:00
ragilap ea294c6a18 add approval projectflockkandang closed,expense must be done,stock must empty by flag unfinished:need info approval fix 2025-12-03 21:46:02 +07:00
ragilap d572d04e3b Merge branch 'dev/ragil' of https://gitlab.com/mbugroup/lti-api into feat/BE/US-279/closing-produksi 2025-12-03 19:56:00 +07:00
aguhh18 beee88322a FIX[BE] : fixing wrong project flock validation 2025-12-03 14:23:32 +07:00
aguhh18 1b464884c5 Feat[BE-290]: enhance expense update functionality and validation 2025-12-03 12:02:58 +07:00
aguhh18 31699f4162 FIX[BE]: fixing nonstock sometimes isn't appeared on get one 2025-12-03 11:14:26 +07:00
aguhh18 e667d88218 Feat[BE]: create resubmit projectflock API 2025-12-02 12:39:58 +07:00
aguhh18 002981e63b Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-02 09:35:02 +07:00
aguhh18 1d0ef8fb93 Feat[BE#280]:add project budgets to body create API and get one API 2025-12-02 09:32:42 +07:00
ragilap d76db26a4d feat/BE/US-279/Closing unfinished 2025-12-01 16:49:13 +07:00
aguhh18 29f0fd6edb Merge branch 'dev/ragil' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-11-28 15:25:52 +07:00
251 changed files with 17041 additions and 2749 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -3,7 +3,7 @@ root = "."
tmp_dir = "tmp" tmp_dir = "tmp"
[build] [build]
cmd = "go build -o ./tmp/main ./cmd/api" cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api"
bin = "tmp/main" bin = "tmp/main"
full_bin = "APP_ENV=dev ./tmp/main" full_bin = "APP_ENV=dev ./tmp/main"
include_ext = ["go", "tpl", "tmpl", "html"] include_ext = ["go", "tpl", "tmpl", "html"]
+8
View File
@@ -9,6 +9,7 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 github.com/aws/aws-sdk-go-v2/credentials v1.19.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1
github.com/bytedance/sonic v1.12.1 github.com/bytedance/sonic v1.12.1
github.com/glebarez/sqlite v1.11.0
github.com/go-playground/validator/v10 v10.27.0 github.com/go-playground/validator/v10 v10.27.0
github.com/gofiber/contrib/jwt v1.0.10 github.com/gofiber/contrib/jwt v1.0.10
github.com/gofiber/fiber/v2 v2.52.5 github.com/gofiber/fiber/v2 v2.52.5
@@ -45,8 +46,10 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-cmp v0.6.0 // indirect
@@ -70,6 +73,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/philhofer/fwd v1.1.2 // indirect github.com/philhofer/fwd v1.1.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect
@@ -94,4 +98,8 @@ require (
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
) )
+19
View File
@@ -65,12 +65,18 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -88,6 +94,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
@@ -184,6 +192,9 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -344,4 +355,12 @@ gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
-44
View File
@@ -1,44 +0,0 @@
package capabilities
import (
"strings"
recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings"
)
// FromPermissions returns a filtered map of capabilities that the frontend can use
// to toggle features. Only permissions recognized by the application are exposed.
func FromPermissions(perms []string) map[string]bool {
if len(perms) == 0 {
return nil
}
out := make(map[string]bool)
for _, perm := range perms {
if key, ok := normalizeAndAllow(perm); ok {
out[key] = true
}
}
if len(out) == 0 {
return nil
}
return out
}
func normalizeAndAllow(perm string) (string, bool) {
perm = strings.ToLower(strings.TrimSpace(perm))
if perm == "" {
return "", false
}
if _, ok := allowed[perm]; !ok {
return "", false
}
return perm, true
}
var allowed = map[string]struct{}{
recordings.PermissionRecordingRead: {},
recordings.PermissionRecordingCreate: {},
recordings.PermissionRecordingUpdate: {},
recordings.PermissionRecordingDelete: {},
}
@@ -84,8 +84,9 @@ func (r *approvalRepositoryImpl) LatestByTargets(
result := make(map[uint]entity.Approval, len(approvableIDs)) result := make(map[uint]entity.Approval, len(approvableIDs))
q := r.DB().WithContext(ctx). q := r.DB().WithContext(ctx).
Select("DISTINCT ON (approvable_id) *").
Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs). Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs).
Order("action_at DESC") Order("approvable_id, action_at DESC")
if modifier != nil { if modifier != nil {
q = modifier(q) q = modifier(q)
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -9,45 +10,59 @@ import (
// Exists reports whether a record with the given ID exists for type T. // Exists reports whether a record with the given ID exists for type T.
func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) { func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) {
var count int64 var marker int
if err := db.WithContext(ctx). err := db.WithContext(ctx).
Model(new(T)). Model(new(T)).
Select("1").
Where("id = ?", id). Where("id = ?", id).
Count(&count).Error; err != nil { Limit(1).
Take(&marker).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err return false, err
} }
return count > 0, nil return true, nil
} }
func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) { func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) {
var count int64
q := db.WithContext(ctx). q := db.WithContext(ctx).
Model(new(T)). Model(new(T)).
Select("1").
Where("name = ?", name). Where("name = ?", name).
Where("deleted_at IS NULL") Where("deleted_at IS NULL")
if excludeID != nil { if excludeID != nil {
q = q.Where("id <> ?", *excludeID) q = q.Where("id <> ?", *excludeID)
} }
if err := q.Count(&count).Error; err != nil { var marker int
if err := q.Limit(1).Take(&marker).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err return false, err
} }
return count > 0, nil return true, nil
} }
func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) { func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) {
if field == "" { if field == "" {
return false, fmt.Errorf("field is required") return false, fmt.Errorf("field is required")
} }
var count int64
q := db.WithContext(ctx). q := db.WithContext(ctx).
Model(new(T)). Model(new(T)).
Select("1").
Where(fmt.Sprintf("%s = ?", field), value). Where(fmt.Sprintf("%s = ?", field), value).
Where("deleted_at IS NULL") Where("deleted_at IS NULL")
if excludeID != nil { if excludeID != nil {
q = q.Where("id <> ?", *excludeID) q = q.Where("id <> ?", *excludeID)
} }
if err := q.Count(&count).Error; err != nil { var marker int
if err := q.Limit(1).Take(&marker).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err return false, err
} }
return count > 0, nil return true, nil
} }
@@ -63,13 +63,14 @@ func (r *StockAllocationRepositoryImpl) ReleaseByUsable(
updates["note"] = *note updates["note"] = *note
} }
q := r.DB().WithContext(ctx). baseDB := r.DB()
if modifier != nil {
baseDB = modifier(baseDB)
}
q := baseDB.WithContext(ctx).
Model(&entity.StockAllocation{}). Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
if modifier != nil {
q = modifier(q)
}
return q.Updates(updates).Error return q.Updates(updates).Error
} }
@@ -0,0 +1,120 @@
package service
import (
"context"
"errors"
"fmt"
productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Dipakai untuk semua module yang butuh cek:
// "PW ini → warehouse → kandang → project_flock_kandang sudah closing atau belum"
func EnsureProjectFlockNotClosedForProductWarehouses(
ctx context.Context,
db *gorm.DB,
productWarehouseIDs []uint,
) error {
if len(productWarehouseIDs) == 0 {
return nil
}
pwRepo := productWarehouseRepo.NewProductWarehouseRepository(db)
wRepo := warehouseRepo.NewWarehouseRepository(db)
pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
seenPW := make(map[uint]struct{})
seenKandang := make(map[uint]struct{})
for _, pwID := range productWarehouseIDs {
if pwID == 0 {
continue
}
if _, ok := seenPW[pwID]; ok {
continue
}
seenPW[pwID] = struct{}{}
pw, err := pwRepo.GetByID(ctx, pwID, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Product warehouse %d tidak ditemukan", pwID))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse")
}
wh, err := wRepo.GetByID(ctx, uint(pw.WarehouseId), nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Warehouse %d tidak ditemukan", pw.WarehouseId))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse")
}
// Warehouse tanpa kandang → bukan kandang produksi → skip
if wh.KandangId == nil || *wh.KandangId == 0 {
continue
}
kandangID := uint(*wh.KandangId)
if _, ok := seenKandang[kandangID]; ok {
continue
}
seenKandang[kandangID] = struct{}{}
pfk, err := pfkRepo.GetActiveByKandangID(ctx, kandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// nggak ada project aktif untuk kandang ini → aman
continue
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
}
// INTI RULE: kalau aktif tapi sudah punya ClosedAt → anggap "project sudah closing"
if pfk != nil && pfk.ClosedAt != nil {
return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
}
}
return nil
}
func EnsureProjectFlockNotClosedByProjectFlockKandangID(
ctx context.Context,
db *gorm.DB,
pfkIDs []uint,
) error {
pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
seen := make(map[uint]struct{})
for _, id := range pfkIDs {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
pfk, err := pfkRepo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Project flock kandang %d tidak ditemukan", id))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
}
if pfk.ClosedAt != nil {
return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
}
}
return nil
}
+19 -6
View File
@@ -505,12 +505,25 @@ func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWa
var lots []stockLot var lots []stockLot
for key, cfg := range configs { for key, cfg := range configs {
selectStmt := fmt.Sprintf(
"%s AS id, %s AS available_qty, %s AS created_at", usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), var selectStmt string
cfg.Columns.CreatedAt, if usesNumericTime {
)
selectStmt = fmt.Sprintf(
"%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at",
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
)
} else {
selectStmt = fmt.Sprintf(
"%s AS id, %s AS available_qty, %s AS created_at",
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
cfg.Columns.CreatedAt,
)
}
var rows []struct { var rows []struct {
ID uint ID uint
@@ -29,7 +29,7 @@ ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id
-- Relasi ke product_warehouses -- Relasi ke product_warehouses
ALTER TABLE project_chickins ALTER TABLE project_chickins
ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE; ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE;
-- Relasi ke users -- Relasi ke users
ALTER TABLE project_chickins ALTER TABLE project_chickins
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_project_flock_kandangs_closed_at;
ALTER TABLE project_flock_kandangs
DROP COLUMN IF EXISTS closed_at;
@@ -0,0 +1,5 @@
ALTER TABLE project_flock_kandangs
ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_project_flock_kandangs_closed_at
ON project_flock_kandangs (closed_at);
@@ -20,7 +20,7 @@ ALTER TABLE product_warehouses
-- Restore audit/soft-delete columns -- Restore audit/soft-delete columns
ALTER TABLE product_warehouses ALTER TABLE product_warehouses
ADD COLUMN IF NOT EXISTS created_by BIGINT NOT NULL REFERENCES users (id), ADD COLUMN IF NOT EXISTS created_by BIGINT REFERENCES users (id),
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(), ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(), ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
@@ -0,0 +1,33 @@
BEGIN;
-- Remove grading details from recording_eggs
ALTER TABLE recording_eggs
DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty;
ALTER TABLE recording_eggs
DROP COLUMN IF EXISTS weight;
ALTER TABLE recording_eggs
ADD CONSTRAINT chk_recording_eggs_qty CHECK (qty >= 0);
-- Restore grading_eggs table for rollback scenarios
CREATE TABLE grading_eggs (
id BIGSERIAL PRIMARY KEY,
recording_egg_id BIGINT NOT NULL,
qty NUMERIC(15,3) NOT NULL,
grade VARCHAR,
created_by BIGINT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_grading_eggs_recording_egg
FOREIGN KEY (recording_egg_id) REFERENCES recording_eggs(id) ON DELETE CASCADE,
CONSTRAINT fk_grading_eggs_created_by
FOREIGN KEY (created_by) REFERENCES users(id),
CONSTRAINT chk_grading_eggs_qty CHECK (qty >= 0)
);
CREATE INDEX idx_grading_eggs_recording_egg
ON grading_eggs (recording_egg_id);
COMMIT;
@@ -0,0 +1,18 @@
BEGIN;
-- Remove separate grading table and move grading details into recording_eggs
DROP INDEX IF EXISTS idx_grading_eggs_recording_egg;
DROP TABLE IF EXISTS grading_eggs;
ALTER TABLE recording_eggs
ADD COLUMN IF NOT EXISTS weight NUMERIC(10,3);
ALTER TABLE recording_eggs
DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty;
ALTER TABLE recording_eggs
ADD CONSTRAINT chk_recording_eggs_qty CHECK (
qty >= 0 AND (weight IS NULL OR weight >= 0)
);
COMMIT;
@@ -0,0 +1,38 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock'
) THEN
ALTER TABLE purchase_items
DROP CONSTRAINT fk_purchase_items_expense_nonstock;
END IF;
IF EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang'
) THEN
ALTER TABLE purchase_items
DROP CONSTRAINT fk_purchase_items_project_flock_kandang;
END IF;
END $$;
DROP INDEX IF EXISTS idx_purchase_items_expense_nonstock_id;
DROP INDEX IF EXISTS idx_purchase_items_project_flock_kandang_id;
ALTER TABLE purchase_items
DROP COLUMN IF EXISTS expense_nonstock_id,
DROP COLUMN IF EXISTS project_flock_kandang_id,
ALTER COLUMN vehicle_number DROP NOT NULL,
ALTER COLUMN vehicle_number TYPE VARCHAR USING vehicle_number;
ALTER TABLE purchases
ALTER COLUMN pr_number TYPE VARCHAR USING pr_number,
ALTER COLUMN po_number TYPE VARCHAR USING po_number,
ALTER COLUMN created_at DROP DEFAULT,
ALTER COLUMN updated_at DROP DEFAULT;
ALTER TABLE purchases
ADD COLUMN credit_term INT NOT NULL DEFAULT 0,
ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0;
ALTER TABLE purchases
ALTER COLUMN credit_term DROP DEFAULT,
ALTER COLUMN grand_total DROP DEFAULT;
@@ -0,0 +1,57 @@
-- Adjust purchases table to new purchasing schema
ALTER TABLE purchases
ALTER COLUMN pr_number TYPE VARCHAR(50) USING LEFT(pr_number, 50),
ALTER COLUMN po_number TYPE VARCHAR(50) USING LEFT(po_number, 50),
ALTER COLUMN created_at SET DEFAULT now(),
ALTER COLUMN updated_at SET DEFAULT now();
ALTER TABLE purchases
DROP COLUMN IF EXISTS credit_term,
DROP COLUMN IF EXISTS grand_total;
-- Bring purchase_items in line with new requirements
ALTER TABLE purchase_items
ADD COLUMN IF NOT EXISTS expense_nonstock_id BIGINT,
ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT;
UPDATE purchase_items
SET vehicle_number = ''
WHERE vehicle_number IS NULL;
ALTER TABLE purchase_items
ALTER COLUMN vehicle_number TYPE VARCHAR(10) USING LEFT(vehicle_number, 10),
ALTER COLUMN vehicle_number SET NOT NULL;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'expense_nonstocks') THEN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock'
) THEN
EXECUTE
'ALTER TABLE purchase_items
ADD CONSTRAINT fk_purchase_items_expense_nonstock
FOREIGN KEY (expense_nonstock_id)
REFERENCES expense_nonstocks(id)
ON DELETE SET NULL ON UPDATE CASCADE';
END IF;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang'
) THEN
EXECUTE
'ALTER TABLE purchase_items
ADD CONSTRAINT fk_purchase_items_project_flock_kandang
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE SET NULL ON UPDATE CASCADE';
END IF;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_purchase_items_expense_nonstock_id
ON purchase_items (expense_nonstock_id);
CREATE INDEX IF NOT EXISTS idx_purchase_items_project_flock_kandang_id
ON purchase_items (project_flock_kandang_id);
@@ -0,0 +1,3 @@
-- Drop function and sequence for sales order numbers
DROP FUNCTION IF EXISTS generate_so_number();
DROP SEQUENCE IF EXISTS so_number_seq;
@@ -0,0 +1,12 @@
-- Create sequence for sales order numbers
CREATE SEQUENCE so_number_seq START WITH 1 INCREMENT BY 1;
CREATE OR REPLACE FUNCTION generate_so_number()
RETURNS VARCHAR AS $$
DECLARE
next_val INTEGER;
BEGIN
next_val := nextval('so_number_seq');
RETURN 'SO-' || LPAD(next_val::TEXT, 5, '0');
END;
$$ LANGUAGE plpgsql;
@@ -0,0 +1,2 @@
ALTER TABLE purchases
DROP COLUMN IF EXISTS credit_term;
@@ -0,0 +1,5 @@
ALTER TABLE purchases
ADD COLUMN IF NOT EXISTS credit_term INT NOT NULL DEFAULT 0;
ALTER TABLE purchases
ALTER COLUMN credit_term DROP DEFAULT;
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_payments_bank_id;
DROP INDEX IF EXISTS payments_party_polymorphic;
DROP TABLE IF EXISTS payments;
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS payments (
id BIGSERIAL PRIMARY KEY,
payment_code VARCHAR(50) NOT NULL,
reference_number VARCHAR(100) NULL,
transaction_type VARCHAR(50),
party_type VARCHAR(50) NOT NULL,
party_id BIGINT NOT NULL,
payment_date TIMESTAMPTZ NOT NULL,
payment_method VARCHAR(20) NOT NULL,
bank_id BIGINT NULL REFERENCES banks(id) ON DELETE RESTRICT ON UPDATE CASCADE,
direction VARCHAR(5) NOT NULL,
nominal NUMERIC(15, 3) NOT NULL,
notes TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
-- Indexes
CREATE INDEX payments_party_polymorphic ON payments (party_type, party_id);
CREATE INDEX idx_payments_bank_id ON payments (bank_id);
@@ -0,0 +1,18 @@
DO $$
DECLARE
r record;
trigger_name text;
BEGIN
FOR r IN
SELECT table_schema, table_name
FROM information_schema.columns
WHERE column_name = 'deleted_at'
AND table_schema = 'public'
GROUP BY table_schema, table_name
LOOP
trigger_name := format('trg_soft_delete_fk_%s', r.table_name);
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name);
END LOOP;
END $$;
DROP FUNCTION IF EXISTS soft_delete_handle_fk();
@@ -0,0 +1,126 @@
CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$
DECLARE
fk record;
child_column text;
parent_column text;
parent_value text;
child_has_deleted_at boolean;
ref_exists boolean;
sql text;
BEGIN
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
FOR fk IN
SELECT conrelid::regclass AS child_table,
conkey AS child_cols,
confkey AS parent_cols,
confdeltype
FROM pg_constraint
WHERE contype = 'f'
AND confrelid = TG_RELID
LOOP
IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1
OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN
RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table;
CONTINUE;
END IF;
SELECT attname INTO child_column
FROM pg_attribute
WHERE attrelid = fk.child_table
AND attnum = fk.child_cols[1]
AND NOT attisdropped;
SELECT attname INTO parent_column
FROM pg_attribute
WHERE attrelid = TG_RELID
AND attnum = fk.parent_cols[1]
AND NOT attisdropped;
EXECUTE format('SELECT ($1).%I', parent_column)
INTO parent_value
USING OLD;
SELECT EXISTS (
SELECT 1
FROM pg_attribute
WHERE attrelid = fk.child_table
AND attname = 'deleted_at'
AND NOT attisdropped
) INTO child_has_deleted_at;
IF fk.confdeltype IN ('r', 'a') THEN
sql := format(
'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)',
fk.child_table,
child_column,
CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END
);
EXECUTE sql INTO ref_exists USING parent_value;
IF ref_exists THEN
RAISE EXCEPTION 'Cannot soft delete %, still referenced by %',
TG_TABLE_NAME, fk.child_table;
END IF;
ELSIF fk.confdeltype = 'n' THEN
sql := format(
'UPDATE %s SET %I = NULL WHERE %I = $1 %s',
fk.child_table,
child_column,
child_column,
CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END
);
EXECUTE sql USING parent_value;
ELSIF fk.confdeltype = 'c' THEN
IF child_has_deleted_at THEN
sql := format(
'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL',
fk.child_table,
child_column
);
EXECUTE sql USING parent_value;
ELSE
sql := format(
'DELETE FROM %s WHERE %I = $1',
fk.child_table,
child_column
);
EXECUTE sql USING parent_value;
END IF;
ELSIF fk.confdeltype = 'd' THEN
sql := format(
'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s',
fk.child_table,
child_column,
child_column,
CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END
);
EXECUTE sql USING parent_value;
END IF;
END LOOP;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
DECLARE
r record;
trigger_name text;
BEGIN
FOR r IN
SELECT table_schema, table_name
FROM information_schema.columns
WHERE column_name = 'deleted_at'
AND table_schema = 'public'
GROUP BY table_schema, table_name
LOOP
trigger_name := format('trg_soft_delete_fk_%s', r.table_name);
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name);
EXECUTE format(
'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()',
trigger_name,
r.table_schema,
r.table_name
);
END LOOP;
END $$;
@@ -0,0 +1 @@
DROP SEQUENCE IF EXISTS payments_code_seq;
@@ -0,0 +1 @@
CREATE SEQUENCE IF NOT EXISTS payments_code_seq START WITH 1 INCREMENT BY 1;
@@ -0,0 +1,3 @@
-- Rollback: restore document columns to expenses table
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS document_path JSON;
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS realization_document_path JSON;
@@ -0,0 +1,3 @@
-- Delete document columns from expenses table since we now use Document service with polymorphic relations
ALTER TABLE expenses DROP COLUMN IF EXISTS document_path;
ALTER TABLE expenses DROP COLUMN IF EXISTS realization_document_path;
@@ -0,0 +1,28 @@
-- ============================================
-- Rollback: Remove FIFO fields and restore qty column
-- ============================================
-- STEP 1: Drop indexes
DROP INDEX IF EXISTS idx_marketing_delivery_products_fifo_lookup;
DROP INDEX IF EXISTS idx_marketing_delivery_products_pending_qty;
DROP INDEX IF EXISTS idx_marketing_delivery_products_usage_qty;
DROP INDEX IF EXISTS idx_marketing_delivery_products_created_at;
-- STEP 2: Drop constraints
ALTER TABLE marketing_delivery_products
DROP CONSTRAINT IF EXISTS chk_marketing_delivery_products_fifo_nonneg;
-- STEP 3: Restore qty column from usage_qty data
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) DEFAULT 0 NOT NULL;
-- Migrate data back from usage_qty to qty
UPDATE marketing_delivery_products
SET qty = usage_qty
WHERE qty = 0;
-- STEP 4: Drop FIFO columns
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS usage_qty,
DROP COLUMN IF EXISTS pending_qty,
DROP COLUMN IF EXISTS created_at;
@@ -0,0 +1,58 @@
-- ============================================
-- Add FIFO fields to marketing_delivery_products
-- This migration adds fields needed for FIFO stock management
-- and removes the old qty field in favor of FIFO-based allocation
-- ============================================
-- STEP 0: Drop orphan indexes from previous migration
DROP INDEX IF EXISTS idx_marketing_delivery_products_deleted_at;
-- STEP 1: Add created_at column (required for FIFO ordering)
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
-- STEP 2: Add FIFO tracking fields
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0;
-- STEP 3: Migrate data from old qty to usage_qty for existing records
-- This preserves existing quantity data as allocated quantity
UPDATE marketing_delivery_products
SET
usage_qty = COALESCE(qty, 0),
pending_qty = 0
WHERE usage_qty = 0;
-- STEP 4: Drop the old qty column (replaced by usage_qty + pending_qty)
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS qty;
-- STEP 5: Make FIFO fields NOT NULL
ALTER TABLE marketing_delivery_products
ALTER COLUMN usage_qty SET NOT NULL,
ALTER COLUMN pending_qty SET NOT NULL,
ALTER COLUMN created_at SET NOT NULL;
-- STEP 6: Add constraints to ensure non-negative values
ALTER TABLE marketing_delivery_products
ADD CONSTRAINT chk_marketing_delivery_products_fifo_nonneg CHECK (
usage_qty >= 0 AND
pending_qty >= 0
);
-- STEP 7: Create indexes for FIFO operations
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_created_at
ON marketing_delivery_products(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_usage_qty
ON marketing_delivery_products(usage_qty)
WHERE usage_qty > 0;
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_pending_qty
ON marketing_delivery_products(pending_qty)
WHERE pending_qty > 0;
-- Composite index for FIFO lookups
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_fifo_lookup
ON marketing_delivery_products(marketing_product_id, created_at DESC);
@@ -0,0 +1,7 @@
-- Remove foreign key constraint
ALTER TABLE marketing_delivery_products
DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_product_warehouse;
-- Drop product_warehouse_id column
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS product_warehouse_id;
@@ -0,0 +1,19 @@
-- Add product_warehouse_id column to marketing_delivery_products
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS product_warehouse_id INT NOT NULL DEFAULT 0;
-- Fill product_warehouse_id from marketing_products
UPDATE marketing_delivery_products mdp
SET product_warehouse_id = mp.product_warehouse_id
FROM marketing_products mp
WHERE mdp.marketing_product_id = mp.id
AND mdp.product_warehouse_id = 0;
-- Set NOT NULL constraint
ALTER TABLE marketing_delivery_products
ALTER COLUMN product_warehouse_id SET NOT NULL;
-- Add foreign key constraint
ALTER TABLE marketing_delivery_products
ADD CONSTRAINT fk_marketing_delivery_products_product_warehouse
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id);
@@ -0,0 +1,10 @@
-- Drop indexes
DROP INDEX IF EXISTS idx_standard_growth_details_standard_week;
DROP INDEX IF EXISTS idx_production_standard_details_standard_week;
DROP INDEX IF EXISTS idx_production_standards_project_category;
DROP INDEX IF EXISTS idx_production_standards_deleted_at;
-- Drop tables (in reverse order due to foreign keys)
DROP TABLE IF EXISTS standard_growth_details;
DROP TABLE IF EXISTS production_standard_details;
DROP TABLE IF EXISTS production_standards;
@@ -0,0 +1,96 @@
-- Create production_standards table
CREATE TABLE IF NOT EXISTS production_standards (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
project_category VARCHAR(20) NOT NULL CHECK (project_category IN ('GROWING', 'LAYING')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT
);
-- Create index for deleted_at (soft delete)
CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at);
-- Tambahkan Foreign Key ke users
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
ALTER TABLE production_standards
ADD CONSTRAINT fk_production_standards_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- Index
CREATE INDEX idx_production_standards_created_by ON production_standards(created_by);
-- Create production_standard_details table
CREATE TABLE IF NOT EXISTS production_standard_details (
id BIGSERIAL PRIMARY KEY,
production_standard_id BIGINT NOT NULL,
week INT NOT NULL,
target_hen_day_production NUMERIC(15, 3),
target_hen_house_production NUMERIC(15, 3),
target_egg_weight NUMERIC(15, 3),
target_egg_mass NUMERIC(15, 3),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tambahkan Foreign Key ke production_standards
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN
ALTER TABLE production_standard_details
ADD CONSTRAINT fk_production_standard_details_standard
FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE;
END IF;
END $$;
-- Create unique constraint for standard_id + week
CREATE UNIQUE INDEX idx_production_standard_details_standard_week
ON production_standard_details(production_standard_id, week);
-- Create standard_growth_details table
CREATE TABLE IF NOT EXISTS standard_growth_details (
id BIGSERIAL PRIMARY KEY,
production_standard_id BIGINT NOT NULL,
target_mean_bw NUMERIC(15, 3),
max_depletion NUMERIC(15, 3),
min_uniformity NUMERIC(15, 3) NOT NULL,
week INT NOT NULL,
feed_intake NUMERIC(15, 3),
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by BIGINT
);
-- Tambahkan Foreign Key ke production_standards
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN
ALTER TABLE standard_growth_details
ADD CONSTRAINT fk_standard_growth_details_standard
FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE;
END IF;
END $$;
-- Tambahkan Foreign Key ke users
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
ALTER TABLE standard_growth_details
ADD CONSTRAINT fk_standard_growth_details_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- Create unique constraint for standard_id + week
CREATE UNIQUE INDEX idx_standard_growth_details_standard_week
ON standard_growth_details(production_standard_id, week);
-- Index
CREATE INDEX idx_standard_growth_details_created_by ON standard_growth_details(created_by);
-- Create index for project_category
CREATE INDEX idx_production_standards_project_category ON production_standards(project_category);
@@ -0,0 +1,24 @@
-- Rollback: Update expense and expense_nonstocks tables
-- Drop indexes
DROP INDEX IF EXISTS idx_expenses_project_flock_id;
DROP INDEX IF EXISTS idx_expenses_location_id;
-- Drop Foreign Key constraint
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_expenses_location_id'
) THEN
ALTER TABLE expenses
DROP CONSTRAINT fk_expenses_location_id;
END IF;
END $$;
-- Drop columns from expenses table
ALTER TABLE expenses
DROP COLUMN IF EXISTS project_flock_id;
ALTER TABLE expenses
DROP COLUMN IF EXISTS location_id;
@@ -0,0 +1,29 @@
-- Migration: Update expense and expense_nonstocks tables
-- Add location_id column to expenses table
ALTER TABLE expenses
ADD COLUMN IF NOT EXISTS location_id BIGINT NOT NULL DEFAULT 1;
-- Add project_flock_id column to expenses table (JSON type)
ALTER TABLE expenses
ADD COLUMN IF NOT EXISTS project_flock_id JSON NULL;
-- Add Foreign Key constraint to locations table
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'locations') THEN
ALTER TABLE expenses
ADD CONSTRAINT fk_expenses_location_id
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
-- Create index for location_id
CREATE INDEX IF NOT EXISTS idx_expenses_location_id ON expenses (location_id);
-- Create index for project_flock_id
CREATE INDEX IF NOT EXISTS idx_expenses_project_flock_id ON expenses ((project_flock_id::text));
-- Ensure kandang_id is nullable in expense_nonstocks table
ALTER TABLE expense_nonstocks
ALTER COLUMN kandang_id DROP NOT NULL;
@@ -0,0 +1,42 @@
-- ===============================================================
-- ROLLBACK: Remove FIFO fields from STOCK_TRANSFER_DETAILS
-- ===============================================================
-- Drop indexes
DROP INDEX IF EXISTS idx_stock_transfer_details_dest_pw;
DROP INDEX IF EXISTS idx_stock_transfer_details_source_pw;
-- Drop foreign keys
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_source_pw'
) THEN
EXECUTE 'ALTER TABLE stock_transfer_details
DROP CONSTRAINT fk_stock_transfer_details_source_pw';
END IF;
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_dest_pw'
) THEN
EXECUTE 'ALTER TABLE stock_transfer_details
DROP CONSTRAINT fk_stock_transfer_details_dest_pw';
END IF;
END $$;
-- Drop FIFO columns
ALTER TABLE stock_transfer_details
DROP COLUMN IF EXISTS total_used,
DROP COLUMN IF EXISTS total_qty,
DROP COLUMN IF EXISTS pending_qty,
DROP COLUMN IF EXISTS usage_qty,
DROP COLUMN IF EXISTS dest_product_warehouse_id,
DROP COLUMN IF EXISTS source_product_warehouse_id;
-- Restore original columns (in case rollback)
ALTER TABLE stock_transfer_details
ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3),
ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3);
@@ -0,0 +1,83 @@
-- ===============================================================
-- ADD FIFO FIELDS TO STOCK_TRANSFER_DETAILS
-- Enable transfer module to work with FIFO stock system
--
-- Notes:
-- - Field 'quantity' will be removed (replaced by usage_qty + pending_qty)
-- - Fields 'before_quantity' & 'after_quantity' will be removed (unused legacy)
-- - New FIFO fields track actual allocation instead of requested quantity
-- ===============================================================
-- Add FIFO tracking fields
ALTER TABLE stock_transfer_details
ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT,
ADD COLUMN IF NOT EXISTS dest_product_warehouse_id BIGINT,
ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_used NUMERIC(15, 3) DEFAULT 0;
-- Remove obsolete columns (quantity replaced by FIFO fields, legacy fields never used)
ALTER TABLE stock_transfer_details
DROP COLUMN IF EXISTS quantity,
DROP COLUMN IF EXISTS before_quantity,
DROP COLUMN IF EXISTS after_quantity;
-- Add foreign keys for product warehouse references
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN
-- Source warehouse foreign key
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_source_pw'
) THEN
EXECUTE
'ALTER TABLE stock_transfer_details
ADD CONSTRAINT fk_stock_transfer_details_source_pw
FOREIGN KEY (source_product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE SET NULL ON UPDATE CASCADE';
END IF;
-- Destination warehouse foreign key
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_dest_pw'
) THEN
EXECUTE
'ALTER TABLE stock_transfer_details
ADD CONSTRAINT fk_stock_transfer_details_dest_pw
FOREIGN KEY (dest_product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE SET NULL ON UPDATE CASCADE';
END IF;
END IF;
END $$;
-- Add indexes for FIFO operations
CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_source_pw
ON stock_transfer_details (source_product_warehouse_id);
CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_dest_pw
ON stock_transfer_details (dest_product_warehouse_id);
-- Add comments for documentation
COMMENT ON COLUMN stock_transfer_details.source_product_warehouse_id IS
'Source product warehouse ID - referensi warehouse asal (FIFO usable)';
COMMENT ON COLUMN stock_transfer_details.dest_product_warehouse_id IS
'Destination product warehouse ID - referensi warehouse tujuan (FIFO stockable)';
COMMENT ON COLUMN stock_transfer_details.usage_qty IS
'Actual quantity successfully taken from source warehouse (FIFO usable tracking) - replaces quantity field';
COMMENT ON COLUMN stock_transfer_details.pending_qty IS
'Quantity waiting for stock availability (FIFO usable tracking)';
COMMENT ON COLUMN stock_transfer_details.total_qty IS
'Total lot quantity available at destination warehouse (FIFO stockable tracking)';
COMMENT ON COLUMN stock_transfer_details.total_used IS
'Quantity already consumed from this lot at destination warehouse (FIFO stockable tracking)';
@@ -0,0 +1,16 @@
-- Rollback: Drop adjustment_stocks table
BEGIN;
DROP INDEX IF EXISTS idx_adjustment_stocks_product_warehouse;
DROP INDEX IF EXISTS idx_adjustment_stocks_stock_log;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_product_warehouse;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_stock_log;
DROP TABLE IF EXISTS adjustment_stocks;
COMMIT;
@@ -0,0 +1,40 @@
-- Migration: Create adjustment_stocks table for FIFO tracking
-- This table tracks FIFO allocation for stock adjustments (both increase and decrease)
BEGIN;
CREATE TABLE IF NOT EXISTS adjustment_stocks (
id BIGSERIAL PRIMARY KEY,
stock_log_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
-- FIFO fields for Adjustment INCREASE (Stockable)
-- Tracks stock added to warehouse via adjustment
total_qty NUMERIC(15, 3) DEFAULT 0,
total_used NUMERIC(15, 3) DEFAULT 0,
-- FIFO fields for Adjustment DECREASE (Usable)
-- Tracks stock consumed from warehouse via adjustment
usage_qty NUMERIC(15, 3) DEFAULT 0,
pending_qty NUMERIC(15, 3) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Foreign keys
ALTER TABLE adjustment_stocks
ADD CONSTRAINT fk_adjustment_stocks_stock_log
FOREIGN KEY (stock_log_id) REFERENCES stock_logs(id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE adjustment_stocks
ADD CONSTRAINT fk_adjustment_stocks_product_warehouse
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id)
ON DELETE CASCADE ON UPDATE CASCADE;
-- Indexes
CREATE INDEX idx_adjustment_stocks_stock_log ON adjustment_stocks(stock_log_id);
CREATE INDEX idx_adjustment_stocks_product_warehouse ON adjustment_stocks(product_warehouse_id);
COMMIT;
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_project_flocks_production_standard_id;
ALTER TABLE project_flocks
DROP CONSTRAINT IF EXISTS fk_project_flocks_production_standard_id;
ALTER TABLE project_flocks
DROP COLUMN IF EXISTS production_standard_id;
@@ -0,0 +1,15 @@
-- Add production_standard_id to project_flocks
ALTER TABLE project_flocks
ADD COLUMN IF NOT EXISTS production_standard_id BIGINT;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN
ALTER TABLE project_flocks
ADD CONSTRAINT fk_project_flocks_production_standard_id
FOREIGN KEY (production_standard_id) REFERENCES production_standards (id) ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_project_flocks_production_standard_id
ON project_flocks (production_standard_id);
+17 -2
View File
@@ -588,6 +588,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Uom: "Ekor", Uom: "Ekor",
Category: "Day Old Chick", Category: "Day Old Chick",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagAyamAfkir},
}, },
{ {
Name: "Ayam Mati", Name: "Ayam Mati",
@@ -596,6 +597,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Uom: "Ekor", Uom: "Ekor",
Category: "Day Old Chick", Category: "Day Old Chick",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagAyamMati},
}, },
{ {
Name: "Ayam Culling", Name: "Ayam Culling",
@@ -604,6 +606,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Uom: "Ekor", Uom: "Ekor",
Category: "Day Old Chick", Category: "Day Old Chick",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagAyamCulling},
}, },
{ {
Name: "Telur Konsumsi Baik", Name: "Telur Konsumsi Baik",
@@ -612,6 +615,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Uom: "Unit", Uom: "Unit",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurUtuh},
}, },
{ {
Name: "Telur Pecah", Name: "Telur Pecah",
@@ -620,6 +624,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Uom: "Unit", Uom: "Unit",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPecah},
}, },
{ {
Name: "281 SPECIAL STARTER", Name: "281 SPECIAL STARTER",
@@ -632,6 +637,16 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"},
Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter}, Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter},
}, },
{
Name: "Ayam Layer",
Brand: "-",
Sku: "LYR0001",
Uom: "Ekor",
Category: "Pullet",
Price: 20000,
Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"},
Flags: []utils.FlagType{utils.FlagLayer},
},
} }
for _, seed := range seeds { for _, seed := range seeds {
@@ -947,12 +962,12 @@ func seedTransferStock(tx *gorm.DB) error {
{ {
StockTransferId: transfer.Id, StockTransferId: transfer.Id,
ProductId: 1, ProductId: 1,
Quantity: 10, // Quantity: 10,
}, },
{ {
StockTransferId: transfer.Id, StockTransferId: transfer.Id,
ProductId: 2, ProductId: 2,
Quantity: 5, // Quantity: 5,
}, },
} }
for i := range details { for i := range details {
+29
View File
@@ -0,0 +1,29 @@
package entities
import "time"
// AdjustmentStock tracks FIFO allocation for stock adjustments
// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse
// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse
type AdjustmentStock struct {
Id uint `gorm:"primaryKey"`
StockLogId uint `gorm:"column:stock_log_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
// === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) ===
// Tracks stock added to warehouse via adjustment INCREASE
TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available
TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot
// === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) ===
// Tracks stock consumed from warehouse via adjustment DECREASE
UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed
PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock)
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
// Relations
StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
}
+9 -7
View File
@@ -1,7 +1,6 @@
package entities package entities
import ( import (
"database/sql"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -13,8 +12,8 @@ type Expense struct {
SupplierId uint64 `gorm:""` SupplierId uint64 `gorm:""`
Category string `gorm:"type:varchar(50);not null"` Category string `gorm:"type:varchar(50);not null"`
PoNumber string `gorm:"type:varchar(50)"` PoNumber string `gorm:"type:varchar(50)"`
DocumentPath sql.NullString `gorm:"type:json"` LocationId uint64 `gorm:"not null"`
RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` ProjectFlockId *string `gorm:"type:json"`
RealizationDate time.Time `gorm:"type:date;column:realization_date"` RealizationDate time.Time `gorm:"type:date;column:realization_date"`
TransactionDate time.Time `gorm:"type:date;not null"` TransactionDate time.Time `gorm:"type:date;not null"`
Notes string `gorm:"type:text;column:notes"` Notes string `gorm:"type:text;column:notes"`
@@ -23,8 +22,11 @@ type Expense struct {
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Location *Location `gorm:"foreignKey:LocationId;references:Id"`
Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"`
Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"`
RealizationDocuments []Document `gorm:"foreignKey:DocumentableId;references:Id"`
LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"`
} }
+30
View File
@@ -0,0 +1,30 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type Initial struct {
Id uint `gorm:"primaryKey;autoIncrement"`
ReferenceNumber string `gorm:"type:varchar(100);not null"`
TransactionType string `gorm:"type:varchar(50);not null"`
InitialBalanceType string `gorm:"type:varchar(20);not null"`
PartyType string `gorm:"type:varchar(50);not null;index:initials_party_polymorphic,priority:1"`
PartyId uint `gorm:"not null;index:initials_party_polymorphic,priority:2"`
BankId *uint `gorm:"index"`
Direction string `gorm:"type:varchar(5);not null"`
Nominal float64 `gorm:"type:numeric(15,3);not null"`
Notes string `gorm:"type:text;not null"`
CreatedBy uint `gorm:"index" json:"-"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Bank Bank `gorm:"foreignKey:BankId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Customer *Customer `gorm:"foreignKey:PartyId;references:Id"`
Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"`
LatestApproval *Approval `gorm:"-" json:"-"`
}
+1
View File
@@ -20,5 +20,6 @@ type Kandang struct {
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"`
Pic User `gorm:"foreignKey:PicId;references:Id"` Pic User `gorm:"foreignKey:PicId;references:Id"`
Warehouses []Warehouse `gorm:"foreignKey:KandangId;references:Id"`
ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"` ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"`
} }
@@ -5,15 +5,20 @@ import (
) )
type MarketingDeliveryProduct struct { type MarketingDeliveryProduct struct {
Id uint `gorm:"primaryKey;autoIncrement"` Id uint `gorm:"primaryKey;autoIncrement"`
MarketingProductId uint `gorm:"uniqueIndex;not null"` MarketingProductId uint `gorm:"uniqueIndex;not null"`
Qty float64 `gorm:"type:numeric(15,3)"` ProductWarehouseId uint `gorm:"not null"`
UnitPrice float64 `gorm:"type:numeric(15,3)"` UnitPrice float64 `gorm:"type:numeric(15,3)"`
TotalWeight float64 `gorm:"type:numeric(15,3)"` TotalWeight float64 `gorm:"type:numeric(15,3)"`
AvgWeight float64 `gorm:"type:numeric(15,3)"` AvgWeight float64 `gorm:"type:numeric(15,3)"`
TotalPrice float64 `gorm:"type:numeric(15,3)"` TotalPrice float64 `gorm:"type:numeric(15,3)"`
DeliveryDate *time.Time `gorm:"type:timestamptz"` DeliveryDate *time.Time `gorm:"type:timestamptz"`
VehicleNumber string `gorm:"type:varchar(50)"` VehicleNumber string `gorm:"type:varchar(50)"`
// FIFO Fields
UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
CreatedAt *time.Time `gorm:"type:timestamptz;not null"`
MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"`
} }
+32
View File
@@ -0,0 +1,32 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type Payment struct {
Id uint `gorm:"primaryKey;autoIncrement"`
PaymentCode string `gorm:"type:varchar(50);not null"`
ReferenceNumber *string `gorm:"type:varchar(100)"`
TransactionType string `gorm:"type:varchar(50)"`
PartyType string `gorm:"type:varchar(50);not null;index:payments_party_polymorphic,priority:1"`
PartyId uint `gorm:"not null;index:payments_party_polymorphic,priority:2"`
PaymentDate time.Time `gorm:"not null"`
PaymentMethod string `gorm:"type:varchar(20);not null"`
BankId *uint `gorm:"not null;index:idx_payments_bank_id"`
Direction string `gorm:"type:varchar(5);not null"`
Nominal float64 `gorm:"type:numeric(15,3);not null"`
Notes string `gorm:"type:text;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedBy uint `gorm:"index" json:"-"`
BankWarehouse Bank `gorm:"foreignKey:BankId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Customer *Customer `gorm:"foreignKey:PartyId;references:Id"`
Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"`
LatestApproval *Approval `gorm:"-" json:"-"`
}
+4 -3
View File
@@ -8,7 +8,8 @@ type ProductWarehouse struct {
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
// Relations // Relations
Product Product `gorm:"foreignKey:ProductId;references:Id"` Product Product `gorm:"foreignKey:ProductId;references:Id"`
Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"`
StockLogs []StockLog `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
StockLogs []StockLog `gorm:"foreignKey:ProductWarehouseId;references:Id"`
} }
+19
View File
@@ -0,0 +1,19 @@
package entities
import (
"time"
)
type ProductionStandard struct {
Id uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);uniqueIndex;not null"`
ProjectCategory string `gorm:"type:varchar(20);not null"`
CreatedAt time.Time `gorm:"type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"type:timestamptz;not null"`
DeletedAt *time.Time `gorm:"type:timestamptz"`
CreatedBy uint `gorm:"not null"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
ProductionStandardDetails []ProductionStandardDetail `gorm:"foreignKey:ProductionStandardId;references:Id"`
StandardGrowthDetails []StandardGrowthDetail `gorm:"foreignKey:ProductionStandardId;references:Id"`
}
@@ -0,0 +1,19 @@
package entities
import (
"time"
)
type ProductionStandardDetail struct {
Id uint `gorm:"primaryKey;autoIncrement"`
ProductionStandardId uint `gorm:"not null"`
Week int `gorm:"not null"`
TargetHenDayProduction *float64 `gorm:"type:numeric(15,3)"`
TargetHenHouseProduction *float64 `gorm:"type:numeric(15,3)"`
TargetEggWeight *float64 `gorm:"type:numeric(15,3)"`
TargetEggMass *float64 `gorm:"type:numeric(15,3)"`
CreatedAt time.Time `gorm:"type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"type:timestamptz;not null"`
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
}
+8 -6
View File
@@ -5,11 +5,13 @@ import (
) )
type ProjectBudget struct { type ProjectBudget struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Qty float64 `gorm:"type:numeric(15,3);not null"` ProjectFlockId uint `gorm:"not null"`
Price float64 `gorm:"type:numeric(15,3);not null"` NonstockId uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` Qty float64 `gorm:"type:numeric(15,3);not null"`
Price float64 `gorm:"type:numeric(15,3);not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
Nonstock *Nonstock `gorm:"foreignKey:Id;references:Id"` Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"`
ProjectFlock *ProjectFlock `gorm:"foreignKey:Id;references:Id"` ProjectFlock *ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"`
} }
+3
View File
@@ -12,6 +12,7 @@ type ProjectFlock struct {
AreaId uint `gorm:"not null"` AreaId uint `gorm:"not null"`
Category string `gorm:"type:varchar(20);not null"` Category string `gorm:"type:varchar(20);not null"`
FcrId uint `gorm:"not null"` FcrId uint `gorm:"not null"`
ProductionStandardId uint `gorm:"column:production_standard_id"`
LocationId uint `gorm:"not null"` LocationId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
@@ -20,10 +21,12 @@ type ProjectFlock struct {
Area Area `gorm:"foreignKey:AreaId;references:Id"` Area Area `gorm:"foreignKey:AreaId;references:Id"`
Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"`
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Kandangs []Kandang `gorm:"many2many:project_flock_kandangs;joinTableForeignKey:project_flock_id;joinTableReferences:kandang_id" json:"kandangs,omitempty"` Kandangs []Kandang `gorm:"many2many:project_flock_kandangs;joinTableForeignKey:project_flock_id;joinTableReferences:kandang_id" json:"kandangs,omitempty"`
KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"` KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"`
Budgets []ProjectBudget `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"`
LatestApproval *Approval `gorm:"-" json:"-"` LatestApproval *Approval `gorm:"-" json:"-"`
} }
+6 -5
View File
@@ -3,11 +3,12 @@ package entities
import "time" import "time"
type ProjectFlockKandang struct { type ProjectFlockKandang struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"`
KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"`
Period int `gorm:"not null"` Period int `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` ClosedAt *time.Time `gorm:"index"`
CreatedAt time.Time `gorm:"autoCreateTime"`
ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"`
Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"`
+3 -4
View File
@@ -5,19 +5,18 @@ import (
) )
type Purchase struct { type Purchase struct {
Id uint `gorm:"primaryKey;autoIncrement"` Id uint `gorm:"primaryKey;autoIncrement"`
PrNumber string `gorm:"not null"` PrNumber string `gorm:"not null"`
PoNumber *string PoNumber *string
PoDate *time.Time PoDate *time.Time
SupplierId uint `gorm:"not null"` SupplierId uint `gorm:"not null"`
CreditTerm *int CreditTerm int `gorm:"column:credit_term;not null;default:0"`
DueDate *time.Time DueDate *time.Time
GrandTotal float64 `gorm:"type:numeric(15,3);default:0"`
Notes *string Notes *string
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt *time.Time `gorm:"index"` DeletedAt *time.Time `gorm:"index"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
// Relations // Relations
Supplier Supplier `gorm:"foreignKey:SupplierId;references:Id"` Supplier Supplier `gorm:"foreignKey:SupplierId;references:Id"`
+17 -14
View File
@@ -5,22 +5,25 @@ import (
) )
type PurchaseItem struct { type PurchaseItem struct {
Id uint `gorm:"primaryKey;autoIncrement"` Id uint `gorm:"primaryKey;autoIncrement"`
PurchaseId uint `gorm:"not null"` PurchaseId uint `gorm:"not null"`
ProductId uint `gorm:"not null"` ProductId uint `gorm:"not null"`
WarehouseId uint `gorm:"not null"` WarehouseId uint `gorm:"not null"`
ProductWarehouseId *uint ProductWarehouseId *uint
ReceivedDate *time.Time ProjectFlockKandangId *uint
TravelNumber *string ReceivedDate *time.Time
TravelNumberDocs *string TravelNumber *string
VehicleNumber *string TravelNumberDocs *string
SubQty float64 `gorm:"type:numeric(15,3);not null"` VehicleNumber *string
TotalQty float64 `gorm:"type:numeric(15,3);default:0"` SubQty float64 `gorm:"type:numeric(15,3);not null"`
TotalUsed float64 `gorm:"type:numeric(15,3);default:0"` TotalQty float64 `gorm:"type:numeric(15,3);default:0"`
Price float64 `gorm:"type:numeric(15,3);default:0"` TotalUsed float64 `gorm:"type:numeric(15,3);default:0"`
TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` Price float64 `gorm:"type:numeric(15,3);default:0"`
TotalPrice float64 `gorm:"type:numeric(15,3);default:0"`
ExpenseNonstockId *uint64
// Relations // Relations
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"` Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"`
Product *Product `gorm:"foreignKey:ProductId;references:Id"` Product *Product `gorm:"foreignKey:ProductId;references:Id"`
Warehouse *Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` Warehouse *Warehouse `gorm:"foreignKey:WarehouseId;references:Id"`
+1 -14
View File
@@ -7,24 +7,11 @@ type RecordingEgg struct {
RecordingId uint `gorm:"column:recording_id;not null;index"` RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Qty int `gorm:"column:qty;not null"` Qty int `gorm:"column:qty;not null"`
Weight *float64 `gorm:"column:weight"`
CreatedBy uint `gorm:"column:created_by"` CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
GradingEggs []GradingEgg `gorm:"foreignKey:RecordingEggId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
} }
type GradingEgg struct {
Id uint `gorm:"primaryKey"`
RecordingEggId uint `gorm:"column:recording_egg_id;not null;index"`
Qty float64 `gorm:"column:qty;not null"`
Grade string `gorm:"column:grade;type:varchar(50)"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
RecordingEgg RecordingEgg `gorm:"foreignKey:RecordingEggId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
}
@@ -0,0 +1,19 @@
package entities
import (
"time"
)
type StandardGrowthDetail struct {
Id uint `gorm:"primaryKey;autoIncrement"`
ProductionStandardId uint `gorm:"not null"`
TargetMeanBw *float64 `gorm:"type:numeric(15,3)"`
MaxDepletion *float64 `gorm:"type:numeric(15,3)"`
MinUniformity float64 `gorm:"type:numeric(15,3);not null"`
Week int `gorm:"not null"`
FeedIntake *float64 `gorm:"type:numeric(15,3)"`
CreatedAt time.Time `gorm:"type:timestamptz;not null"`
CreatedBy uint `gorm:"not null"`
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
}
+1
View File
@@ -20,4 +20,5 @@ type StockTransfer struct {
Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"`
Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"`
CreatedUser *User `gorm:"foreignKey:CreatedBy"` CreatedUser *User `gorm:"foreignKey:CreatedBy"`
Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
} }
-10
View File
@@ -2,16 +2,6 @@ package entities
import "time" import "time"
const (
LogTypeAdjustment = "ADJUSTMENT"
LogTypeTransfer = "TRANSFER"
)
const (
TransactionTypeIncrease = "INCREASE"
TransactionTypeDecrease = "DECREASE"
)
type StockLog struct { type StockLog struct {
Id uint `gorm:"primaryKey;column:id"` Id uint `gorm:"primaryKey;column:id"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"`
+18 -17
View File
@@ -4,20 +4,21 @@ import "time"
// DETAIL EKSPEDISI // DETAIL EKSPEDISI
type StockTransferDelivery struct { type StockTransferDelivery struct {
Id uint64 `gorm:"primaryKey;autoIncrement"` Id uint64 `gorm:"primaryKey;autoIncrement"`
StockTransferId uint64 StockTransferId uint64
SupplierId uint64 SupplierId uint64
VehiclePlate string VehiclePlate string
DriverName string DriverName string
DocumentNumber string DocumentNumber string
DocumentPath string DocumentPath string
ShippingCostItem float64 ShippingCostItem float64
ShippingCostTotal float64 ShippingCostTotal float64
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DeletedAt *time.Time `gorm:"index"` DeletedAt *time.Time `gorm:"index"`
// Relations // Relations
StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"`
Supplier *Supplier `gorm:"foreignKey:SupplierId"` Supplier *Supplier `gorm:"foreignKey:SupplierId"`
Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"`
} Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
+24 -8
View File
@@ -7,12 +7,28 @@ type StockTransferDetail struct {
Id uint64 `gorm:"primaryKey;autoIncrement"` Id uint64 `gorm:"primaryKey;autoIncrement"`
StockTransferId uint64 StockTransferId uint64
ProductId uint64 ProductId uint64
Quantity float64
CreatedAt time.Time // === FIFO FIELDS - SOURCE WAREHOUSE (Usable) ===
UpdatedAt time.Time // Tracking stock yang DIAMBIL dari source warehouse
DeletedAt *time.Time `gorm:"index"` SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"`
// Relations UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil
StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock)
Product *Product `gorm:"foreignKey:ProductId"`
DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` // === FIFO FIELDS - DESTINATION WAREHOUSE (Stockable) ===
// Tracking stock yang DITAMBAHKAN ke destination warehouse
DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"`
TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia
TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini
// === METADATA ===
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `gorm:"index"`
// === RELATIONS ===
StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"`
Product *Product `gorm:"foreignKey:ProductId"`
SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"`
DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"`
DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"`
} }
+18
View File
@@ -0,0 +1,18 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type Transaction struct {
Id uint `gorm:"primaryKey"`
Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
}
+70 -4
View File
@@ -3,14 +3,13 @@ package middleware
import ( import (
"strings" "strings"
"github.com/gofiber/fiber/v2"
"gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/sso"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
) )
const ( const (
@@ -91,7 +90,6 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
c.Locals(authContextLocalsKey, ctx) c.Locals(authContextLocalsKey, ctx)
c.Locals(authUserLocalsKey, user) c.Locals(authUserLocalsKey, user)
return c.Next() return c.Next()
} }
} }
@@ -107,7 +105,7 @@ func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) {
func ActorIDFromContext(c *fiber.Ctx) (uint, error) { func ActorIDFromContext(c *fiber.Ctx) (uint, error) {
user, ok := AuthenticatedUser(c) user, ok := AuthenticatedUser(c)
if !ok || user == nil || user.Id == 0 { if !ok || user == nil || user.Id == 0 {
return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
} }
return user.Id, nil return user.Id, nil
@@ -199,3 +197,71 @@ func hasAllScopes(have, required []string) bool {
} }
return true return true
} }
// RequirePermissions ensures the authenticated user possesses all specified permissions.
func RequirePermissions(perms ...string) fiber.Handler {
required := canonicalPermissions(perms)
return func(c *fiber.Ctx) error {
if len(required) == 0 {
return c.Next()
}
ctx, ok := AuthDetails(c)
if !ok || ctx == nil {
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
userPerms := ctx.permissionSet()
if len(userPerms) == 0 {
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission")
}
for _, perm := range required {
if _, has := userPerms[perm]; !has {
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission")
}
}
return c.Next()
}
}
// HasPermission reports whether the current request context includes the given permission.
func HasPermission(c *fiber.Ctx, perm string) bool {
ctx, ok := AuthDetails(c)
if !ok || ctx == nil {
return false
}
perm = canonicalPermission(perm)
if perm == "" {
return false
}
_, has := ctx.permissionSet()[perm]
return has
}
func (a *AuthContext) permissionSet() map[string]struct{} {
if a == nil || a.Permissions == nil {
return nil
}
return a.Permissions
}
func canonicalPermissions(perms []string) []string {
out := make([]string, 0, len(perms))
seen := make(map[string]struct{}, len(perms))
for _, perm := range perms {
if canonical := canonicalPermission(perm); canonical != "" {
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
out = append(out, canonical)
}
}
return out
}
func canonicalPermission(perm string) string {
return strings.ToLower(strings.TrimSpace(perm))
}
+219 -62
View File
@@ -1,75 +1,232 @@
package middleware package middleware
import ( // project-flock
"strings" const (
P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing"
P_ProjectFlockKandangsCheckClosing = "lti.production.project_flock_kandangs.closing.detail"
P_ProjectFlockKandangsGetAll = "lti.production.project_flock_kandangs.list"
P_ProjectFlockKandangsGetOne = "lti.production.project_flock_kandangs.detail"
"github.com/gofiber/fiber/v2" P_ProjectFlockGetAll = "lti.production.project_flocks.list"
P_ProjectFlockCreate = "lti.production.project_flocks.create"
P_ProjectFlockGetOne = "lti.production.project_flocks.detail"
P_ProjectFlockUpdate = "lti.production.project_flocks.update"
P_ProjectFlockDelete = "lti.production.project_flocks.delete"
P_ProjectFlockApprove = "lti.production.project_flocks.approve"
P_ProjectFlockLookup = "lti.production.project_flocks.lookup"
P_ProjectFlockNextPeriod = "lti.production.project_flocks.next_period"
P_ProjectFlockResubmit = "lti.production.project_flocks.resubmit"
) )
// RequirePermissions ensures the authenticated user possesses all specified permissions. const (
func RequirePermissions(perms ...string) fiber.Handler { P_ExpenseGetAll = "lti.expense.list"
required := canonicalPermissions(perms) P_ExpenseCreateOne = "lti.expense.create"
return func(c *fiber.Ctx) error { P_ExpenseUpdateOne = "lti.expense.update"
if len(required) == 0 { P_ExpenseGetOne = "lti.expense.detail"
return c.Next() P_ExpenseDeleteOne = "lti.expense.delete"
} P_ExpenseApprovalManager = "lti.expense.approve.manager"
P_ExpenseApprovalFinance = "lti.expense.approve.finance"
P_ExpenseCreateRealizations = "lti.expense.create.realization"
P_ExpenseUpdateRealizations = "lti.expense.update.realization"
P_ExpenseCompleteExpense = "lti.expense.complete.expense"
P_ExpenseDocument = "lti.expense.document"
P_ExpenseDocumentRealizations = "lti.expense.document.realization"
)
const (
P_AdjustmentGetAll = "lti.inventory.list"
P_AdjustmentCreate = "lti.inventory.create"
P_AdjustmentGetOne = "lti.inventory.detail"
)
const (
P_ApprovalGetAll = "lti.approval.list"
)
const (
P_ReportExpenseGetAll = "lti.repport.expense.list"
P_ReportDeliveryGetAll = "lti.repport.delivery.list"
P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list"
)
ctx, ok := AuthDetails(c) const (
if !ok || ctx == nil { P_ProductStockGetAll = "lti.inventory.product_stock.list"
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") P_ProductStockGetOne = "lti.inventory.product_stock.detail"
} P_ProductWarehousekGetAll = "lti.inventory.product_warehouses.list"
P_ProductWarehouseGetOne = "lti.inventory.product_warehouses.detail"
)
const (
P_ClosingGetAll = "lti.closing.list"
P_ClosingDetail = "lti.closing.detail"
)
userPerms := ctx.permissionSet() const (
if len(userPerms) == 0 { P_TransferGetAll = "lti.inventory.transfer.list"
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") P_TransferGetOne = "lti.inventory.transfer.detail"
} P_TransferCreateOne = "lti.inventory.transfer.create"
)
for _, perm := range required { const (
if _, has := userPerms[perm]; !has { P_TransferToLaying_GetAll = "lti.production.transfer_to_laying.list"
return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") P_TransferToLaying_GetOne = "lti.production.transfer_to_laying.detail"
} P_TransferToLaying_CreateOne = "lti.production.transfer_to_laying.create"
} P_TransferToLaying_UpdateOne = "lti.production.transfer_to_laying.update"
P_TransferToLaying_DeleteOne = "lti.production.transfer_to_laying.delete"
P_TransferToLaying_Approval = "lti.production.transfer_to_laying.approve"
P_TransferToLaying_GetAvailableQty = "lti.production.transfer_to_laying.getavailableqty"
)
return c.Next() const (
} P_DeliveryGetAll = "lti.marketing.delivery_order.list"
} P_DeliveryGetOne = "lti.marketing.delivery_order.detail"
P_DeliveryUpdateOne = "lti.marketing.delivery_order.update"
P_DeliveryCreateOne = "lti.marketing.delivery_order.Create"
P_SalesOrderDelete = "lti.marketing.sales_order.delete"
P_SalesOrderApproval = "lti.marketing.sales_order.approve"
P_SalesOrderCreateOne = "lti.marketing.sales_order.create"
P_SalesOrderUpdateOne = "lti.marketing.sales_order.update"
)
// HasPermission reports whether the current request context includes the given permission. const (
func HasPermission(c *fiber.Ctx, perm string) bool { P_AreaGetAll = "lti.master.area.list"
ctx, ok := AuthDetails(c) P_AreaGetOne = "lti.master.area.detail"
if !ok || ctx == nil { P_AreaCreateOne = "lti.master.area.create"
return false P_AreaUpdateOne = "lti.master.area.update"
} P_AreaDeleteOne = "lti.master.area.delete"
perm = canonicalPermission(perm)
if perm == "" {
return false
}
_, has := ctx.permissionSet()[perm]
return has
}
func (a *AuthContext) permissionSet() map[string]struct{} { P_BanksGetAll = "lti.master.banks.list"
if a == nil || a.Permissions == nil { P_BanksGetOne = "lti.master.banks.detail"
return nil P_BanksCreateOne = "lti.master.banks.create"
} P_BanksUpdateOne = "lti.master.banks.update"
return a.Permissions P_BanksDeleteOne = "lti.master.banks.delete"
}
func canonicalPermissions(perms []string) []string { P_CustomerGetAll = "lti.master.customer.list"
out := make([]string, 0, len(perms)) P_CustomerGetOne = "lti.master.customer.detail"
seen := make(map[string]struct{}, len(perms)) P_CustomerCreateOne = "lti.master.customer.create"
for _, perm := range perms { P_CustomerUpdateOne = "lti.master.customer.update"
if canonical := canonicalPermission(perm); canonical != "" { P_CustomerDeleteOne = "lti.master.customer.delete"
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
out = append(out, canonical)
}
}
return out
}
func canonicalPermission(perm string) string { P_FcrGetAll = "lti.master.fcr.list"
return strings.ToLower(strings.TrimSpace(perm)) P_FcrGetOne = "lti.master.fcr.detail"
} P_FcrCreateOne = "lti.master.fcr.create"
P_FcrUpdateOne = "lti.master.fcr.update"
P_FcrDeleteOne = "lti.master.fcr.delete"
P_FlocksGetAll = "lti.master.flocks.list"
P_FlocksGetOne = "lti.master.flocks.detail"
P_FlocksCreateOne = "lti.master.flocks.create"
P_FlocksUpdateOne = "lti.master.flocks.update"
P_FlocksDeleteOne = "lti.master.flocks.delete"
P_KandangsGetAll = "lti.master.kandangs.list"
P_KandangsGetOne = "lti.master.kandangs.detail"
P_KandangsCreateOne = "lti.master.kandangs.create"
P_KandangsUpdateOne = "lti.master.kandangs.update"
P_KandangsDeleteOne = "lti.master.kandangs.delete"
P_LocationsGetAll = "lti.master.locations.list"
P_LocationsGetOne = "lti.master.locations.detail"
P_LocationsCreateOne = "lti.master.locations.create"
P_LocationsUpdateOne = "lti.master.locations.update"
P_LocationsDeleteOne = "lti.master.locations.delete"
P_NonstocksGetAll = "lti.master.nonstocks.list"
P_NonstocksGetOne = "lti.master.nonstocks.detail"
P_NonstocksCreateOne = "lti.master.nonstocks.create"
P_NonstocksUpdateOne = "lti.master.nonstocks.update"
P_NonstocksDeleteOne = "lti.master.nonstocks.delete"
P_ProductCategoriesGetAll = "lti.master.Product_categories.list"
P_ProductCategoriesGetOne = "lti.master.Product_categories.detail"
P_ProductCategoriesCreateOne = "lti.master.Product_categories.create"
P_ProductCategoriesUpdateOne = "lti.master.Product_categories.update"
P_ProductCategoriesDeleteOne = "lti.master.Product_categories.delete"
P_ProductsGetAll = "lti.master.Products.list"
P_ProductsGetOne = "lti.master.Products.detail"
P_ProductsCreateOne = "lti.master.Products.create"
P_ProductsUpdateOne = "lti.master.Products.update"
P_ProductsDeleteOne = "lti.master.Products.delete"
P_SuppliersGetAll = "lti.master.suppliers.list"
P_SuppliersGetOne = "lti.master.suppliers.detail"
P_SuppliersCreateOne = "lti.master.suppliers.create"
P_SuppliersUpdateOne = "lti.master.suppliers.update"
P_SuppliersDeleteOne = "lti.master.suppliers.delete"
P_UomsGetAll = "lti.master.uoms.list"
P_UomsGetOne = "lti.master.uoms.detail"
P_UomsCreateOne = "lti.master.uoms.create"
P_UomsUpdateOne = "lti.master.uoms.update"
P_UomsDeleteOne = "lti.master.uoms.delete"
P_WarehousesGetAll = "lti.master.warehouses.list"
P_WarehousesGetOne = "lti.master.warehouses.detail"
P_WarehousesCreateOne = "lti.master.warehouses.create"
P_WarehousesUpdateOne = "lti.master.warehouses.update"
P_WarehousesDeleteOne = "lti.master.warehouses.delete"
P_Production_Standart_GetAll = "lti.master.production_standards.list"
P_Production_Standart_CreateOne = "lti.master.production_standards.create"
P_Production_Standart_GetOne = "lti.master.production_standards.detail"
P_Production_Standart_UpdateOne = "lti.master.production_standards.update"
P_Production_Standart_DeleteOne = "lti.master.production_standards.delete"
)
// finance
const (
P_Finances_Initial_Balances_CreateOne = "lti.finance.initial_balances.create"
P_Finances_Initial_Balances_GetOne = "lti.finance.initial_balances.detail"
P_Finances_Initial_Balances_UpdateOne = "lti.finance.initial_balances.update"
P_Finances_Injections_CreateOne = "lti.finance.injections.create"
P_Finances_Injections_GetOne = "lti.finance.injections.detail"
P_Finances_Injections_UpdateOne = "lti.finance.injections.update"
P_Finances_Payments_CreateOne = "lti.finance.payments.create"
P_Finances_Payments_UpdateOne = "lti.finance.payments.update"
P_Finances_Payments_GetOne = "lti.finance.payments.detail"
P_Finances_Transaction_GetAll = "lti.finance.transactions.list"
P_Finances_Transaction_GetOne = "lti.finance.transactions.detail"
P_Finances_Transaction_DeleteOne = "lti.finance.transactions.delete"
)
const (
P_ChickinsCreateOne = "lti.production.chickins.create"
P_ChickinsGetOne = "lti.production.chickins.detail"
P_ChickinsApproval = "lti.production.chickins.approve"
)
// recording
const (
P_RecordingGetAll = "lti.production.recording.list"
P_RecordingGetOne = "lti.production.recording.detail"
P_RecordingCreateOne = "lti.production.recording.create"
P_RecordingUpdateOne = "lti.production.recording.update"
P_RecordingDeleteOne = "lti.production.recording.delete"
P_RecordingNextDay = "lti.production.recording.next_day"
P_RecordingApproval = "lti.production.recording.approve"
)
const (
P_PurchaseGetAll = "lti.Purchase.list"
P_PurchaseGetOne = "lti.Purchase.detail"
P_PurchaseCreateOne = "lti.Purchase.create"
P_PurchaseUpdateOne = "lti.Purchase.update"
P_PurchaseDeleteOne = "lti.Purchase.delete"
P_PurchaseItemDeleteOne = "lti.Purchase.delete.item"
P_PurchaseReceive = "lti.Purchase.receive"
P_PurchaseApprovalStaff = "lti.Purchase.approve.staff"
P_PurchaseApprovalManager = "lti.Purchase.approve.manager"
)
const (
P_FinanceGetAll = "lti.finance.list"
P_FinanceGetOne = "lti.finance.detail"
P_FinanceCreateOne = "lti.finance.create"
P_FinanceUpdateOne = "lti.finance.update"
P_FinanceDeleteOne = "lti.finance.delete"
P_FinanceApproval = "lti.finance.approve"
)
const (
P_UserGetAll = "lti.users.list"
P_UserGetOne = "lti.users.detail"
)
+1 -1
View File
@@ -15,5 +15,5 @@ func ApprovalRoutes(v1 fiber.Router, u user.UserService, s common.ApprovalServic
route := v1.Group("/approvals") route := v1.Group("/approvals")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll) route.Get("/", ctrl.GetAll,m.RequirePermissions(m.P_ApprovalGetAll))
} }
@@ -3,6 +3,7 @@ package controller
import ( import (
"math" "math"
"strconv" "strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
@@ -13,12 +14,14 @@ import (
) )
type ClosingController struct { type ClosingController struct {
ClosingService service.ClosingService ClosingService service.ClosingService
SapronakService service.SapronakService
} }
func NewClosingController(closingService service.ClosingService) *ClosingController { func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService) *ClosingController {
return &ClosingController{ return &ClosingController{
ClosingService: closingService, ClosingService: closingService,
SapronakService: sapronakService,
} }
} }
@@ -39,17 +42,17 @@ func (u *ClosingController) GetAll(c *fiber.Ctx) error {
} }
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.ClosingListDTO]{ JSON(response.SuccessWithPaginate[dto.ClosingListItemDTO]{
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
Message: "Get all closings successfully", Message: "Retrieved closing projects list successfully",
Meta: response.Meta{ Meta: response.Meta{
Page: query.Page, Page: query.Page,
Limit: query.Limit, Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults, TotalResults: totalResults,
}, },
Data: dto.ToClosingListDTOs(result), Data: result,
}) })
} }
@@ -57,11 +60,11 @@ func (u *ClosingController) GetOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
id, err := strconv.Atoi(param) id, err := strconv.Atoi(param)
if err != nil { if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") return fiber.NewError(fiber.StatusBadRequest, "Invalid id")
} }
result, err := u.ClosingService.GetOne(c, uint(id)) result, err := u.ClosingService.GetProjectFlockByID(c, uint(id))
if err != nil { if err != nil {
return err return err
} }
@@ -70,7 +73,281 @@ func (u *ClosingController) GetOne(c *fiber.Ctx) error {
JSON(response.Success{ JSON(response.Success{
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
Message: "Get closing successfully", Message: "Retrieved closing information successfully",
Data: dto.ToClosingListDTO(*result), Data: result,
})
}
func (u *ClosingController) GetClosingSummary(c *fiber.Ctx) error {
param := c.Params("projectFlockId")
id, err := strconv.Atoi(param)
if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId")
}
result, err := u.ClosingService.GetClosingSummary(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Retrieved project information successfully",
Data: result,
})
}
func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error {
param := c.Params("project_flock_id")
projectFlockID, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id")
}
projectFlock, err := u.ClosingService.GetProjectFlockByID(c, uint(projectFlockID))
if err != nil {
return err
}
result, err := u.ClosingService.GetPenjualan(c, uint(projectFlockID))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get closing penjualan successfully",
Data: dto.ToPenjualanRealisasiResponseDTO(projectFlock.Category, uint(projectFlockID), result),
})
}
func (u *ClosingController) GetOverhead(c *fiber.Ctx) error {
param := c.Params("project_flock_id")
projectFlockID, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id")
}
result, err := u.ClosingService.GetOverhead(c, uint(projectFlockID))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get overhead successfully",
Data: result,
})
}
func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error {
param := c.Params("projectFlockId")
id, err := strconv.Atoi(param)
if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId")
}
query := &validation.ClosingSapronakQuery{
Type: strings.ToLower(c.Query("type")),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing {
return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
}
result, totalResults, err := u.ClosingService.GetClosingSapronak(c, uint(id), query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.ClosingSapronakItemDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Retrieved closing report (sapronak) successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: result,
})
}
func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
param := c.Params("project_flock_id")
flag := c.Query("flag", "")
projectID, err := strconv.Atoi(param)
if err != nil || projectID <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
}
result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
if err != nil {
return err
}
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag)
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get perhitungan sapronak per project successfully",
Data: payload,
})
}
func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
projectParam := c.Params("project_flock_id")
kandangParam := c.Params("project_flock_kandang_id")
flag := c.Query("flag", "")
projectID, err := strconv.Atoi(projectParam)
if err != nil || projectID <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
}
pfkID, err := strconv.Atoi(kandangParam)
if err != nil || pfkID <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
}
result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
if err != nil {
return err
}
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag)
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get perhitungan sapronak per kandang successfully",
Data: payload,
})
}
func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error {
param := c.Params("project_flock_id")
projectFlockID, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id")
}
result, err := u.ClosingService.GetClosingKeuangan(c, uint(projectFlockID))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get closing keuangan successfully",
Data: result,
})
}
func (u *ClosingController) GetExpeditionHPP(c *fiber.Ctx) error {
param := c.Params("project_flock_id")
projectFlockID, err := strconv.Atoi(param)
if err != nil || projectFlockID <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
}
var projectFlockKandangID *uint
if raw := c.Query("project_flock_kandang_id"); raw != "" {
idInt, convErr := strconv.Atoi(raw)
if convErr != nil || idInt <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
}
idUint := uint(idInt)
projectFlockKandangID = &idUint
}
result, err := u.ClosingService.GetExpeditionHPP(c, uint(projectFlockID), projectFlockKandangID)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get expedition HPP successfully",
Data: result,
})
}
func (u *ClosingController) GetExpeditionHPPByKandang(c *fiber.Ctx) error {
projectParam := c.Params("project_flock_id")
kandangParam := c.Params("project_flock_kandang_id")
projectFlockID, err := strconv.Atoi(projectParam)
if err != nil || projectFlockID <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
}
pfkID, err := strconv.Atoi(kandangParam)
if err != nil || pfkID <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
}
kandangID := uint(pfkID)
result, err := u.ClosingService.GetExpeditionHPP(c, uint(projectFlockID), &kandangID)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get expedition HPP successfully",
Data: result,
})
}
func (u *ClosingController) GetClosingDataProduksi(c *fiber.Ctx) error {
param := c.Params("projectFlockId")
id, err := strconv.Atoi(param)
if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId")
}
result, err := u.ClosingService.GetClosingDataProduksi(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Retrieved production data successfully",
Data: result,
}) })
} }
@@ -1,6 +1,7 @@
package dto package dto
import ( import (
"fmt"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -26,6 +27,149 @@ type ClosingDetailDTO struct {
ClosingListDTO ClosingListDTO
} }
type ClosingListItemDTO struct {
Id uint `json:"id"`
ProjectName string `json:"project_name"`
LocationID uint `json:"location_id"`
LocationName string `json:"location_name"`
ProjectCategory string `json:"project_category"`
Period int `json:"period"`
ClosingDate string `json:"closing_date"`
ShedLabel string `json:"shed_label"`
ShedCount int `json:"shed_count"`
// SalesPaidAmount int64 `json:"sales_paid_amount"`
// SalesRemainingAmount int64 `json:"sales_remaining_amount"`
// SalesPaymentStatus string `json:"sales_payment_status"`
ProjectStatus string `json:"project_status"`
}
type ClosingSummaryDTO struct {
FlockID uint `json:"flock_id"`
Period int `json:"period"`
// JenisProduk string `json:"jenis_produk"`
// LabelPopulasi string `json:"label_populasi"`
Population int `json:"population"`
PopulationFormatted string `json:"population_formatted"`
ProjectType string `json:"project_type"`
ActiveHouseCount int `json:"active_house_count"`
ActiveHouseLabel string `json:"active_house_label"`
SalesPaymentStatus string `json:"sales_payment_status"`
// StatusPembayaranMitra string `json:"status_pembayaran_mitra"`
StatusProject string `json:"project_status"`
StatusClosing string `json:"closing_status"`
}
type ClosingPurchaseDTO struct {
InitialPopulation int `json:"initial_population"`
ClaimCulling int `json:"claim_culling"`
FinalPopulation int `json:"final_population"`
FeedIn float64 `json:"feed_in"`
FeedUsed float64 `json:"feed_used"`
FeedUsedPerHead float64 `json:"feed_used_per_head"`
}
type ClosingSalesDTO struct {
SalesPopulation int `json:"sales_population"`
SalesWeight float64 `json:"sales_weight"`
AverageWeight float64 `json:"average_weight"`
AverageSellingPrice float64 `json:"chicken_average_selling_price"`
}
type ClosingEggSalesDTO struct {
EggPieces int `json:"egg_pieces"`
EggMassKg float64 `json:"egg_mass_kg"`
AverageEggWeightKg float64 `json:"average_egg_weight_kg"`
AverageSellingPrice float64 `json:"egg_average_selling_price"`
}
type ClosingPerformanceDTO struct {
Depletion float64 `json:"depletion"`
Age float64 `json:"age_day"`
MortalityStd float64 `json:"mortality_std"`
MortalityAct float64 `json:"mortality_act"`
DeffMortality float64 `json:"deff_mortality"`
FcrStd float64 `json:"fcr_std"`
FcrAct float64 `json:"fcr_act"`
DeffFcr float64 `json:"deff_fcr"`
Awg float64 `json:"awg"`
}
type ClosingSalesGroupDTO struct {
Chicken ClosingSalesDTO `json:"chicken"`
Egg *ClosingEggSalesDTO `json:"egg,omitempty"`
}
type ClosingProductionReportDTO struct {
Purchase ClosingPurchaseDTO `json:"purchase"`
Sales ClosingSalesGroupDTO `json:"sales"`
Performance ClosingPerformanceDTO `json:"performance"`
}
func ToClosingSummaryDTO(project entity.ProjectFlock, statusProject, statusClosing string) ClosingSummaryDTO {
history := project.KandangHistory
period := maxPeriod(history)
kandangCount := len(history)
population := sumPopulation(history)
populationInt := int(population)
return ClosingSummaryDTO{
FlockID: project.Id,
Period: period,
// JenisProduk: project.Category,
// LabelPopulasi: "",
Population: populationInt,
PopulationFormatted: fmt.Sprintf("%d Ekor", populationInt),
ProjectType: project.Category,
ActiveHouseCount: kandangCount,
ActiveHouseLabel: fmt.Sprintf("%d Kandang", kandangCount),
SalesPaymentStatus: "Tempo",
// StatusPembayaranMitra: "",
StatusProject: statusProject,
StatusClosing: statusClosing,
}
}
func ToClosingListItemDTO(project entity.ProjectFlock, projectStatus string) ClosingListItemDTO {
shedCount := len(project.KandangHistory)
return ClosingListItemDTO{
Id: project.Id,
ProjectName: project.FlockName,
LocationID: project.LocationId,
LocationName: project.Location.Name,
ProjectCategory: project.Category,
Period: maxPeriod(project.KandangHistory),
ClosingDate: "17-Nov-2025",
ShedLabel: fmt.Sprintf("%d Kandang", shedCount),
ShedCount: shedCount,
// SalesPaidAmount: 21993726,
// SalesRemainingAmount: 11075919,
// SalesPaymentStatus: "Lunas",
ProjectStatus: projectStatus,
}
}
func maxPeriod(history []entity.ProjectFlockKandang) int {
max := 0
for _, h := range history {
if h.Period > max {
max = h.Period
}
}
return max
}
func sumPopulation(history []entity.ProjectFlockKandang) float64 {
var total float64
for _, h := range history {
for _, chickin := range h.Chickins {
total += chickin.UsageQty + chickin.PendingUsageQty
}
}
return total
}
// === Mapper Functions === // === Mapper Functions ===
func ToClosingRelationDTO(e entity.ProjectFlock) ClosingRelationDTO { func ToClosingRelationDTO(e entity.ProjectFlock) ClosingRelationDTO {
@@ -62,3 +206,20 @@ func ToClosingDetailDTO(e entity.ProjectFlock) ClosingDetailDTO {
ClosingListDTO: ToClosingListDTO(e), ClosingListDTO: ToClosingListDTO(e),
} }
} }
func CalculateAgeFromChickinDataProduksi(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0
}
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
for _, chickin := range projectFlockKandang.Chickins {
if chickin.ChickInDate.Before(earliestChickinDate) {
earliestChickinDate = chickin.ChickInDate
}
}
ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24)
ageInWeeks := ageInDays / 7
return ageInWeeks
}
@@ -0,0 +1,14 @@
package dto
// ExpeditionCostItemDTO merepresentasikan biaya ekspedisi per vendor.
type ExpeditionCostItemDTO struct {
Id uint64 `json:"id"`
ExpeditionVendorName string `json:"expedition_vendor_name"`
HPPAmount float64 `json:"hpp_amount"`
}
// ExpeditionHPPDTO adalah struktur response utama untuk HPP Ekspedisi.
type ExpeditionHPPDTO struct {
ExpeditionCosts []ExpeditionCostItemDTO `json:"expedition_costs"`
TotalHPPAmount float64 `json:"total_hpp_amount"`
}
@@ -0,0 +1,589 @@
package dto
import (
"slices"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === CONSTANTS ===
const (
HPPGroupPengeluaran = "HPP dan Pengeluaran"
HPPGroupBahanBaku = "HPP dan Bahan Baku"
HPPLabelOverhead = "Pengeluaran Overhead"
HPPLabelEkspedisi = "Beban Ekspedisi"
HPPSummaryLabel = "HPP"
PLSalesTypeChicken = "Penjualan Ayam Besar"
PLSalesTypeEgg = "Penjualan Telur"
PLItemTypeSapronak = "Pembelian Sapronak"
PLItemTypeOverhead = "Pengeluaran Overhead"
PLItemTypeEkspedisi = "Beban Ekspedisi"
PLSummaryLabelGrossProfit = "LABA RUGI BRUTTO"
PLSummaryLabelSubTotal = "SUB TOTAL"
PLSummaryLabelNetProfit = "LABA RUGI NETTO"
PurchaseLabelPrefix = "Pembelian "
)
// === CONTEXT STRUCTS ===
type CalculationContext struct {
TotalPopulation float64
TotalWeightProduced float64
TotalEggWeightKg float64
TotalDepletion float64
TotalWeightSold float64
ActualPopulation float64
}
type ClosingKeuanganInput struct {
ProjectFlockCategory string
PurchaseItems []entities.PurchaseItem
Budgets []entities.ProjectBudget
Realizations []entities.ExpenseRealization
DeliveryProducts []entities.MarketingDeliveryProduct
Chickins []entities.ProjectChickin
TotalWeightProduced float64
TotalEggWeightKg float64
TotalDepletion float64
}
// === BASE METRICS ===
type FinancialMetrics struct {
RpPerBird float64 `json:"rp_per_bird"`
RpPerKg float64 `json:"rp_per_kg"`
Amount float64 `json:"amount"`
}
type Comparison struct {
Budgeting FinancialMetrics `json:"budgeting"`
Realization FinancialMetrics `json:"realization"`
}
// === HPP PURCHASES PACKAGE ===
type HppItem struct {
Type string `json:"type"`
Comparison
}
type HppGroup struct {
GroupName string `json:"group_name"`
Data []HppItem `json:"data"`
}
type SummaryHpp struct {
Label string `json:"label"`
Comparison `json:"-"`
EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"`
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"`
}
type HppPurchasesSection struct {
Hpp []HppGroup `json:"hpp"`
SummaryHpp SummaryHpp `json:"summary_hpp"`
}
// === PROFIT LOSS PACKAGE ===
type PLItem struct {
Type string `json:"type"`
FinancialMetrics
}
type PLSummaryItem struct {
Label string `json:"label"`
FinancialMetrics
}
type PLSummaryGroup struct {
GrossProfit PLSummaryItem `json:"gross_profit"`
SubTotal PLSummaryItem `json:"sub_total"`
NetProfit PLSummaryItem `json:"net_profit"`
}
type ProfitLossData struct {
Penjualan []PLItem `json:"penjualan"`
Pembelian []PLItem `json:"pembelian"`
Overhead PLItem `json:"overhead"`
Ekspedisi PLItem `json:"ekspedisi"`
Summary PLSummaryGroup `json:"summary"`
}
type ProfitLossSection struct {
Data ProfitLossData `json:"data"`
}
// === RESPONSE DTO (ROOT) ===
type ReportResponse struct {
HppPurchases HppPurchasesSection `json:"hpp_purchases"`
ProfitLoss ProfitLossSection `json:"profit_loss"`
}
// === MAPPER FUNCTIONS ===
func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
return FinancialMetrics{
RpPerBird: rpPerBird,
RpPerKg: rpPerKg,
Amount: amount,
}
}
func ToComparison(budgeting, realization FinancialMetrics) Comparison {
return Comparison{
Budgeting: budgeting,
Realization: realization,
}
}
// === HPP PENGELUARAN (from Purchase Items) ===
func getFlagLabel(flagType utils.FlagType) string {
return PurchaseLabelPrefix + string(flagType)
}
func buildHppItemsByPurchaseFlags(purchaseItems []entities.PurchaseItem, ctx CalculationContext) []HppItem {
flags := []utils.FlagType{
utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, utils.FlagPakan,
utils.FlagPreStarter, utils.FlagStarter, utils.FlagFinisher,
utils.FlagOVK, utils.FlagObat, utils.FlagVitamin, utils.FlagKimia,
}
items := []HppItem{}
seenFlags := make(map[utils.FlagType]bool)
for _, item := range purchaseItems {
if item.Product == nil || len(item.Product.Flags) == 0 {
continue
}
for _, flag := range item.Product.Flags {
flagType := utils.FlagType(flag.Name)
if slices.Contains(flags, flagType) && !seenFlags[flagType] {
amount := sumPurchasesByFlag(purchaseItems, flagType)
rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.TotalPopulation, ctx.TotalWeightProduced)
items = append(items, HppItem{
Type: getFlagLabel(flagType),
Comparison: ToComparison(
ToFinancialMetrics(rpPerBird, rpPerKg, amount),
ToFinancialMetrics(rpPerBird, rpPerKg, amount),
),
})
seenFlags[flagType] = true
}
}
}
return items
}
// === HPP BAHAN BAKU (from ProjectBudget + ExpenseRealization) ===
func createHppOverheadItem(budgetAmount, realizationAmount float64, ctx CalculationContext) HppItem {
budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(budgetAmount, ctx.TotalPopulation, ctx.TotalWeightProduced)
realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(realizationAmount, ctx.TotalPopulation, ctx.TotalWeightProduced)
return HppItem{
Type: HPPLabelOverhead,
Comparison: ToComparison(
ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount),
ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount),
),
}
}
func createHppEkspedisiItem(ekspedisiAmount float64, ctx CalculationContext) HppItem {
ekspedisiRpPerBird, ekspedisiRpPerKg := calculatePerUnitMetrics(ekspedisiAmount, ctx.TotalPopulation, ctx.TotalWeightProduced)
return HppItem{
Type: HPPLabelEkspedisi,
Comparison: ToComparison(
ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount),
ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount),
),
}
}
func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppGroup {
items := []HppItem{}
budgetAmount := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true })
realizationAmount := getOperationalExpenses(realizations)
if budgetAmount > 0 || realizationAmount > 0 {
items = append(items, createHppOverheadItem(budgetAmount, realizationAmount, ctx))
}
ekspedisiAmount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi))
items = append(items, createHppEkspedisiItem(ekspedisiAmount, ctx))
return HppGroup{
GroupName: HPPGroupBahanBaku,
Data: items,
}
}
// === HPP SUMMARY ===
func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp {
purchaseTotal := sumPurchaseTotal(purchaseItems)
budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true })
totalBudget := purchaseTotal + budgetTotal
totalRealization := sumRealizationsByFilter(realizations, func(*entities.ExpenseRealization) bool { return true })
budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced)
realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced)
summary := SummaryHpp{
Label: label,
Comparison: ToComparison(
ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget),
ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization),
),
}
if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 {
budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg)
realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg)
summary.EggBudgeting = &FinancialMetrics{
RpPerBird: 0,
RpPerKg: budgetEggRpPerKg,
Amount: totalBudget,
}
summary.EggRealization = &FinancialMetrics{
RpPerBird: 0,
RpPerKg: realizationEggRpPerKg,
Amount: totalRealization,
}
}
return summary
}
func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection {
hppGroups := []HppGroup{
{
GroupName: HPPGroupPengeluaran,
Data: buildHppItemsByPurchaseFlags(purchaseItems, ctx),
},
ToHppBahanBakuGroup(budgets, realizations, ctx),
}
summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx)
return HppPurchasesSection{
Hpp: hppGroups,
SummaryHpp: summaryHpp,
}
}
// === PROFIT & LOSS ===
func ToPLItem(itemType string, metrics FinancialMetrics) PLItem {
return PLItem{
Type: itemType,
FinancialMetrics: metrics,
}
}
func ToPLSummaryItem(label string, metrics FinancialMetrics) PLSummaryItem {
return PLSummaryItem{
Label: label,
FinancialMetrics: metrics,
}
}
func createPLItemWithMetrics(itemType string, amount float64, ctx CalculationContext) PLItem {
rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightProduced)
return ToPLItem(itemType, ToFinancialMetrics(rpPerBird, rpPerKg, amount))
}
func sumPLItems(items []PLItem) (totalAmount, totalPerBird float64) {
for _, item := range items {
totalAmount += item.Amount
totalPerBird += item.RpPerBird
}
return
}
func createPenjualanItem(salesType string, amount float64, ctx CalculationContext) PLItem {
rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightSold)
return ToPLItem(salesType, ToFinancialMetrics(rpPerBird, rpPerKg, amount))
}
func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.MarketingDeliveryProduct, ctx CalculationContext) []PLItem {
items := []PLItem{}
categorized := categorizeDeliveriesBySalesType(deliveryProducts)
if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) {
ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken])
telurAmount := sumDeliveriesByCategory(categorized[PLSalesTypeEgg])
items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx))
items = append(items, createPenjualanItem(PLSalesTypeEgg, telurAmount, ctx))
} else {
ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken])
items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx))
}
return items
}
func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
purchaseAmount := sumPurchaseTotal(purchases)
return []PLItem{
createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx),
}
}
func ToOverheadItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
realizationAmount := getOperationalExpenses(realizations)
return []PLItem{
createPLItemWithMetrics(PLItemTypeOverhead, realizationAmount, ctx),
}
}
func ToEkspedisiItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
amount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi))
return []PLItem{
createPLItemWithMetrics(PLItemTypeEkspedisi, amount, ctx),
}
}
func ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) PLSummaryGroup {
totalPenjualan, totalPenjualanPerBird := sumPLItems(penjualanItems)
totalPembelian, totalPembelianPerBird := sumPLItems(pembelianItems)
totalOverhead, totalOverheadPerBird := sumPLItems(overheadItems)
totalEkspedisi, totalEkspedisiPerBird := sumPLItems(ekspedisiItems)
grossProfit := totalPenjualan - totalPembelian
grossProfitPerBird := totalPenjualanPerBird - totalPembelianPerBird
totalOtherExpenses := totalOverhead + totalEkspedisi
totalOtherExpensesPerBird := totalOverheadPerBird + totalEkspedisiPerBird
netProfit := grossProfit - totalOtherExpenses
netProfitPerBird := grossProfitPerBird - totalOtherExpensesPerBird
return PLSummaryGroup{
GrossProfit: ToPLSummaryItem(PLSummaryLabelGrossProfit, ToFinancialMetrics(grossProfitPerBird, 0, grossProfit)),
SubTotal: ToPLSummaryItem(PLSummaryLabelSubTotal, ToFinancialMetrics(totalOtherExpensesPerBird, 0, totalOtherExpenses)),
NetProfit: ToPLSummaryItem(PLSummaryLabelNetProfit, ToFinancialMetrics(netProfitPerBird, 0, netProfit)),
}
}
func ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossData {
summary := ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems)
totalOverhead := aggregatePLItems(overheadItems, PLItemTypeOverhead)
totalEkspedisi := aggregatePLItems(ekspedisiItems, PLItemTypeEkspedisi)
return ProfitLossData{
Penjualan: penjualanItems,
Pembelian: pembelianItems,
Overhead: totalOverhead,
Ekspedisi: totalEkspedisi,
Summary: summary,
}
}
func ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossSection {
return ProfitLossSection{
Data: ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems),
}
}
func aggregatePLItems(items []PLItem, label string) PLItem {
totalAmount, totalPerBird := sumPLItems(items)
return ToPLItem(label, ToFinancialMetrics(totalPerBird, 0, totalAmount))
}
func ToReportResponse(hppPurchases HppPurchasesSection, profitLoss ProfitLossSection) ReportResponse {
return ReportResponse{
HppPurchases: hppPurchases,
ProfitLoss: profitLoss,
}
}
func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse {
var totalPopulation float64
var totalWeightSold float64
for _, chickin := range input.Chickins {
totalPopulation += chickin.UsageQty
}
for _, delivery := range input.DeliveryProducts {
totalWeightSold += delivery.TotalWeight
}
ctx := CalculationContext{
TotalPopulation: totalPopulation,
TotalWeightProduced: input.TotalWeightProduced,
TotalEggWeightKg: input.TotalEggWeightKg,
TotalDepletion: input.TotalDepletion,
TotalWeightSold: totalWeightSold,
ActualPopulation: totalPopulation - input.TotalDepletion,
}
hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx)
penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx)
pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx)
overheadItems := ToOverheadItems(input.Realizations, ctx)
ekspedisiItems := ToEkspedisiItems(input.Realizations, ctx)
plSection := ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems)
return ToReportResponse(hppSection, plSection)
}
// === HELPER FUNCTIONS ===
func calculatePerUnitMetrics(amount, totalPopulation, totalWeightSold float64) (rpPerBird, rpPerKg float64) {
if totalPopulation > 0 {
rpPerBird = amount / totalPopulation
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
return rpPerBird, rpPerKg
}
func hasProductFlag(flags []entities.Flag, flagType utils.FlagType) bool {
for _, flag := range flags {
if strings.ToUpper(flag.Name) == string(flagType) {
return true
}
}
return false
}
func filterByPurchaseFlag(flagType utils.FlagType) func(*entities.PurchaseItem) bool {
return func(item *entities.PurchaseItem) bool {
if item.Product == nil || len(item.Product.Flags) == 0 {
return false
}
return hasProductFlag(item.Product.Flags, flagType)
}
}
func filterRealizationByNonstockFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool {
return func(realization *entities.ExpenseRealization) bool {
if realization.ExpenseNonstock == nil || realization.ExpenseNonstock.Nonstock == nil {
return false
}
return hasProductFlag(realization.ExpenseNonstock.Nonstock.Flags, flagType)
}
}
func filterRealizationExceptFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool {
hasFlag := filterRealizationByNonstockFlag(flagType)
return func(realization *entities.ExpenseRealization) bool {
return !hasFlag(realization)
}
}
func sumByFilter[T any](items []T, extractor func(*T) float64, filter func(*T) bool) float64 {
amount := 0.0
for i := range items {
if filter(&items[i]) {
amount += extractor(&items[i])
}
}
return amount
}
func sumPurchasesByFilter(purchases []entities.PurchaseItem, filter func(*entities.PurchaseItem) bool) float64 {
return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, filter)
}
func sumPurchasesByFlag(purchases []entities.PurchaseItem, flagType utils.FlagType) float64 {
return sumPurchasesByFilter(purchases, filterByPurchaseFlag(flagType))
}
func sumPurchaseTotal(purchases []entities.PurchaseItem) float64 {
return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, func(*entities.PurchaseItem) bool { return true })
}
func sumBudgetsByFilter(budgets []entities.ProjectBudget, filter func(*entities.ProjectBudget) bool) float64 {
return sumByFilter(budgets, func(b *entities.ProjectBudget) float64 { return b.Price * b.Qty }, filter)
}
func sumRealizationsByFilter(realizations []entities.ExpenseRealization, filter func(*entities.ExpenseRealization) bool) float64 {
return sumByFilter(realizations, func(r *entities.ExpenseRealization) float64 { return r.Price * r.Qty }, filter)
}
func getOperationalExpenses(realizations []entities.ExpenseRealization) float64 {
return sumRealizationsByFilter(realizations, filterRealizationExceptFlag(utils.FlagEkspedisi))
}
func isChickenProductFlag(flagType utils.FlagType) bool {
switch flagType {
case utils.FlagDOC, utils.FlagPullet, utils.FlagLayer,
utils.FlagAyamAfkir, utils.FlagAyamCulling, utils.FlagAyamMati:
return true
}
return false
}
func isEggProductFlag(flagType utils.FlagType) bool {
switch flagType {
case utils.FlagTelur, utils.FlagTelurUtuh, utils.FlagTelurPecah,
utils.FlagTelurPutih, utils.FlagTelurRetak:
return true
}
return false
}
func getSalesTypeFromProductFlags(product *entities.Product) string {
if product == nil || len(product.Flags) == 0 {
return PLSalesTypeChicken
}
for _, flag := range product.Flags {
flagType := utils.FlagType(strings.ToUpper(flag.Name))
if isEggProductFlag(flagType) {
return PLSalesTypeEgg
}
if isChickenProductFlag(flagType) {
return PLSalesTypeChicken
}
}
return PLSalesTypeChicken
}
func categorizeDeliveriesBySalesType(deliveries []entities.MarketingDeliveryProduct) map[string][]entities.MarketingDeliveryProduct {
categorized := make(map[string][]entities.MarketingDeliveryProduct)
for _, delivery := range deliveries {
product := delivery.MarketingProduct.ProductWarehouse.Product
salesType := getSalesTypeFromProductFlags(&product)
categorized[salesType] = append(categorized[salesType], delivery)
}
return categorized
}
func sumDeliveriesByCategory(deliveries []entities.MarketingDeliveryProduct) float64 {
amount := 0.0
for _, delivery := range deliveries {
amount += delivery.TotalPrice
}
return amount
}
@@ -0,0 +1,119 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
deliveryOrdersDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
)
// === Response DTO ===
type SalesDTO struct {
Id uint `json:"id"`
RealizationDate time.Time `json:"realization_date"`
Age int `json:"age"`
DoNumber string `json:"do_number"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"`
Qty float64 `json:"qty"`
Weight float64 `json:"weight"`
AvgWeight float64 `json:"avg_weight"`
Price float64 `json:"price"`
TotalPrice float64 `json:"total_price"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
PaymentStatus string `json:"payment_status"`
}
type PenjualanRealisasiResponseDTO struct {
Sales []SalesDTO `json:"sales"`
}
// === Mapper Functions ===
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
age := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate)
var product *productDTO.ProductRelationDTO
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
mapped := productDTO.ToProductRelationDTO(e.MarketingProduct.ProductWarehouse.Product)
product = &mapped
}
var customer *customerDTO.CustomerRelationDTO
if e.MarketingProduct.Marketing.Customer.Id != 0 {
mapped := customerDTO.ToCustomerRelationDTO(e.MarketingProduct.Marketing.Customer)
customer = &mapped
}
var kandang *kandangDTO.KandangRelationDTO
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil && e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang)
kandang = &mapped
}
doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id)
return SalesDTO{
Id: e.Id,
RealizationDate: *e.DeliveryDate,
Age: age,
DoNumber: doNumber,
Product: product,
Customer: customer,
Qty: e.UsageQty, // Show allocated quantity from FIFO
Weight: e.TotalWeight,
AvgWeight: e.AvgWeight,
Price: e.UnitPrice,
TotalPrice: e.TotalPrice,
Kandang: kandang,
PaymentStatus: "Paid",
}
}
func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO {
result := make([]SalesDTO, len(e))
for i, r := range e {
result[i] = ToSalesDTO(r)
}
return result
}
func ToPenjualanRealisasiResponseDTO(projectType string, projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO {
return PenjualanRealisasiResponseDTO{
Sales: ToSalesDTOs(e),
}
}
func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int {
if len(realisasi) > 0 {
for _, item := range realisasi {
if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period
}
}
}
return 0
}
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0
}
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
for _, chickin := range projectFlockKandang.Chickins {
if chickin.ChickInDate.Before(earliestChickinDate) {
earliestChickinDate = chickin.ChickInDate
}
}
ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24)
ageInWeeks := ageInDays / 7
return ageInWeeks
}
@@ -0,0 +1,176 @@
package dto
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
// === DTO Structs ===
type OverheadDTO struct {
ItemName string `json:"item_name"`
UOMName string `json:"uom_name"`
BudgetQuantity float64 `json:"budget_quantity"`
BudgetUnitPrice float64 `json:"budget_unit_price"`
BudgetTotalAmount float64 `json:"budget_total_amount"`
ActualDate string `json:"actual_date"`
ActualQuantity float64 `json:"actual_quantity"`
ActualUnitPrice float64 `json:"actual_unit_price"`
ActualTotalAmount float64 `json:"actual_total_amount"`
CostPerBird float64 `json:"cost_per_bird"`
}
type TotalDTO struct {
BudgetQuantity float64 `json:"budget_quantity"`
BudgetTotalAmount float64 `json:"budget_total_amount"`
ActualQuantity float64 `json:"actual_quantity"`
ActualTotalAmount float64 `json:"actual_total_amount"`
CostPerBird float64 `json:"cost_per_bird"`
}
type OverheadListDTO struct {
Total TotalDTO `json:"total"`
Overheads []OverheadDTO `json:"overheads"`
}
// === Mapper Functions ===
func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseRealization) OverheadDTO {
if budget == nil && realization == nil {
return OverheadDTO{}
}
var itemName, itemUOM string
if budget != nil {
itemName, itemUOM = getItemInfo(budget.Nonstock)
}
if itemName == "" && realization != nil && realization.ExpenseNonstock != nil {
itemName, itemUOM = getItemInfo(realization.ExpenseNonstock.Nonstock)
}
dto := OverheadDTO{
ItemName: itemName,
UOMName: itemUOM,
}
if budget != nil {
dto.BudgetQuantity = budget.Qty
dto.BudgetUnitPrice = budget.Price
dto.BudgetTotalAmount = calculateTotal(budget.Qty, budget.Price)
}
if realization != nil {
dto.ActualQuantity = realization.Qty
dto.ActualUnitPrice = realization.Price
dto.ActualTotalAmount = calculateTotal(realization.Qty, realization.Price)
dto.ActualDate = formatRealizationDate(realization)
}
return dto
}
func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64) OverheadListDTO {
overheadsByNonstockID := make(map[uint]*OverheadDTO)
latestDateByNonstockID := make(map[uint]string)
for i := range budgets {
nonstockID := budgets[i].NonstockId
if overheadsByNonstockID[nonstockID] == nil {
overheadsByNonstockID[nonstockID] = &OverheadDTO{}
}
itemName, itemUOM := getItemInfo(budgets[i].Nonstock)
overheadsByNonstockID[nonstockID].ItemName = itemName
overheadsByNonstockID[nonstockID].UOMName = itemUOM
overheadsByNonstockID[nonstockID].BudgetQuantity = budgets[i].Qty
overheadsByNonstockID[nonstockID].BudgetUnitPrice = budgets[i].Price
overheadsByNonstockID[nonstockID].BudgetTotalAmount = calculateTotal(budgets[i].Qty, budgets[i].Price)
}
for i := range realizations {
if realizations[i].ExpenseNonstock == nil || realizations[i].ExpenseNonstock.NonstockId == nil {
continue
}
nonstockID := uint(*realizations[i].ExpenseNonstock.NonstockId)
if overheadsByNonstockID[nonstockID] == nil {
overheadsByNonstockID[nonstockID] = &OverheadDTO{}
}
overheadsByNonstockID[nonstockID].ActualQuantity += realizations[i].Qty
overheadsByNonstockID[nonstockID].ActualTotalAmount += calculateTotal(realizations[i].Qty, realizations[i].Price)
if overheadsByNonstockID[nonstockID].ItemName == "" {
itemName, itemUOM := getItemInfo(realizations[i].ExpenseNonstock.Nonstock)
overheadsByNonstockID[nonstockID].ItemName = itemName
overheadsByNonstockID[nonstockID].UOMName = itemUOM
}
realizationDateStr := formatRealizationDate(&realizations[i])
if realizationDateStr != "" {
if latestDateByNonstockID[nonstockID] == "" || realizationDateStr > latestDateByNonstockID[nonstockID] {
latestDateByNonstockID[nonstockID] = realizationDateStr
}
}
}
var totalBudgetQuantity, totalBudgetAmount, totalActualQuantity, totalActualAmount float64
overheadItems := make([]OverheadDTO, 0, len(overheadsByNonstockID))
for nonstockID, overhead := range overheadsByNonstockID {
overhead.ActualDate = latestDateByNonstockID[nonstockID]
overhead.CostPerBird = calculateCostPerBird(overhead.ActualTotalAmount, totalActualPopulation)
if overhead.ActualQuantity > 0 {
overhead.ActualUnitPrice = overhead.ActualTotalAmount / overhead.ActualQuantity
}
totalBudgetQuantity += overhead.BudgetQuantity
totalBudgetAmount += overhead.BudgetTotalAmount
totalActualQuantity += overhead.ActualQuantity
totalActualAmount += overhead.ActualTotalAmount
overheadItems = append(overheadItems, *overhead)
}
return OverheadListDTO{
Total: TotalDTO{
BudgetQuantity: totalBudgetQuantity,
BudgetTotalAmount: totalBudgetAmount,
ActualQuantity: totalActualQuantity,
ActualTotalAmount: totalActualAmount,
CostPerBird: calculateCostPerBird(totalActualAmount, totalActualPopulation),
},
Overheads: overheadItems,
}
}
// === Helper Functions ===
func getItemInfo(nonstock *entity.Nonstock) (string, string) {
if nonstock != nil && nonstock.Id != 0 {
return nonstock.Name, nonstock.Uom.Name
}
return "", ""
}
func calculateTotal(qty, price float64) float64 {
return qty * price
}
func calculateCostPerBird(totalPrice, totalActualPopulation float64) float64 {
if totalActualPopulation > 0 {
return totalPrice / totalActualPopulation
}
return 0
}
func formatRealizationDate(realization *entity.ExpenseRealization) string {
if realization != nil && realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Expense != nil {
if !realization.ExpenseNonstock.Expense.RealizationDate.IsZero() {
return realization.ExpenseNonstock.Expense.RealizationDate.Format("2006-01-02T15:04:05Z07:00")
}
}
return ""
}
@@ -0,0 +1,252 @@
package dto
import (
"strings"
"time"
)
type SapronakDetailDTO struct {
ProductID uint `json:"product_id"`
ProductName string `json:"product_name"`
Flag string `json:"flag"`
Tanggal *time.Time `json:"tanggal,omitempty"`
NoReferensi string `json:"no_referensi,omitempty"`
JenisTransaksi string `json:"jenis_transaksi,omitempty"`
QtyMasuk float64 `json:"qty_masuk"`
QtyKeluar float64 `json:"qty_keluar"`
Harga float64 `json:"harga"`
Nilai float64 `json:"nilai"`
}
type SapronakGroupDTO struct {
Flag string `json:"flag"`
Items []SapronakDetailDTO `json:"items"`
TotalMasuk float64 `json:"total_masuk"`
TotalKeluar float64 `json:"total_keluar"`
SaldoAkhir float64 `json:"saldo_akhir"`
TotalNilai float64 `json:"total_nilai"`
}
type SapronakItemDTO struct {
ProductID uint `json:"product_id"`
ProductName string `json:"product_name"`
Flag string `json:"flag"`
IncomingQty float64 `json:"incoming_qty"`
IncomingValue float64 `json:"incoming_value"`
UsageQty float64 `json:"usage_qty"`
UsageValue float64 `json:"usage_value"`
RemainingQty float64 `json:"remaining_qty"`
AveragePrice float64 `json:"average_price"`
}
type SapronakReportDTO struct {
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
ProjectFlockID uint `json:"project_flock_id"`
ProjectName string `json:"project_name"`
KandangID uint `json:"kandang_id"`
KandangName string `json:"kandang_name"`
Period int `json:"period"`
Status string `json:"status"`
StartDate *time.Time `json:"start_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
TotalIncomingValue float64 `json:"total_incoming_value"`
TotalUsageValue float64 `json:"total_usage_value"`
Items []SapronakItemDTO `json:"items"`
Groups []SapronakGroupDTO `json:"groups,omitempty"`
}
// Simplified view for project-level sapronak response
type SapronakCategoryRowDTO struct {
ID int `json:"id"`
Date string `json:"date"`
ReferenceNumber string `json:"reference_number"`
QtyIn float64 `json:"qty_in"`
QtyOut float64 `json:"qty_out"`
QtyUsed float64 `json:"qty_used"`
Description string `json:"description"`
ProductCategory string `json:"product_category"`
UnitPrice float64 `json:"unit_price"`
TotalAmount float64 `json:"total_amount"`
Notes string `json:"notes"`
}
type SapronakCategoryTotalDTO struct {
Label string `json:"label"`
QtyIn float64 `json:"qty_in"`
QtyOut float64 `json:"qty_out"`
QtyUsed float64 `json:"qty_used"`
AvgUnitPrice float64 `json:"avg_unit_price"`
TotalAmount float64 `json:"total_amount"`
}
type SapronakCategoryDTO struct {
Rows []SapronakCategoryRowDTO `json:"rows"`
Total SapronakCategoryTotalDTO `json:"total"`
}
type SapronakProjectAggregatedDTO struct {
Doc *SapronakCategoryDTO `json:"doc,omitempty"`
Ovk *SapronakCategoryDTO `json:"ovk,omitempty"`
Pakan *SapronakCategoryDTO `json:"pakan,omitempty"`
Pullet *SapronakCategoryDTO `json:"pullet,omitempty"`
}
type ClosingSapronakItemDTO struct {
Id uint64 `json:"id"`
Date string `json:"date"`
ReferenceNumber string `json:"reference_number"`
TransactionType string `json:"transaction_type"`
ProductName string `json:"product_name"`
ProductCategory string `json:"product_category"`
ProductSubCategory string `json:"product_sub_category"`
SourceWarehouse string `json:"source_warehouse"`
DestinationWarehouse string `json:"destination_warehouse,omitempty"`
// Destination string `json:"destination,omitempty"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
FormattedQuantity string `json:"formatted_quantity"`
Notes string `json:"notes"`
SortDate time.Time `json:"-"`
}
type ClosingSapronakDTO struct {
IncomingSapronak []ClosingSapronakItemDTO `json:"incoming_sapronak"`
OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"`
}
// === Mapper Functions for Aggregated Sapronak Response ===
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{}
if len(reports) == 0 {
return result
}
rep := reports[0]
return ToSapronakProjectAggregatedFromReport(&rep, flag)
}
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{}
if report == nil {
report = &SapronakReportDTO{}
}
filter := strings.ToUpper(strings.TrimSpace(flag))
byFlag := map[string]**SapronakCategoryDTO{}
if filter == "" || filter == "DOC" {
result.Doc = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)}
byFlag["DOC"] = &result.Doc
}
if filter == "" || filter == "OVK" {
result.Ovk = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)}
byFlag["OVK"] = &result.Ovk
}
if filter == "" || filter == "PAKAN" {
result.Pakan = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)}
byFlag["PAKAN"] = &result.Pakan
}
if filter == "" || filter == "PULLET" {
result.Pullet = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)}
byFlag["PULLET"] = &result.Pullet
}
formatDate := func(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("02-Jan-2006")
}
for _, group := range report.Groups {
flagKey := strings.ToUpper(group.Flag)
ptr := byFlag[flagKey]
if ptr == nil || *ptr == nil {
continue
}
target := *ptr
rowIndexByProduct := make(map[string]int)
getOrCreateRow := func(productKey string, base SapronakCategoryRowDTO) *SapronakCategoryRowDTO {
if idx, ok := rowIndexByProduct[productKey]; ok {
return &target.Rows[idx]
}
target.Rows = append(target.Rows, base)
idx := len(target.Rows) - 1
rowIndexByProduct[productKey] = idx
return &target.Rows[idx]
}
for idx, item := range group.Items {
productKey := strings.ToUpper(group.Flag + "|" + item.ProductName)
baseRow := SapronakCategoryRowDTO{
ID: idx + 1,
Date: formatDate(item.Tanggal),
ReferenceNumber: item.NoReferensi,
Description: item.ProductName,
ProductCategory: item.ProductName,
UnitPrice: item.Harga,
Notes: "-",
}
row := getOrCreateRow(productKey, baseRow)
switch strings.ToLower(item.JenisTransaksi) {
case "pembelian", "adjustment masuk", "mutasi masuk":
row.QtyIn += item.QtyMasuk
row.TotalAmount += item.Nilai
case "pemakaian", "adjustment keluar":
row.QtyUsed += item.QtyKeluar
case "mutasi keluar":
row.QtyOut += item.QtyKeluar
default:
row.QtyIn += item.QtyMasuk
row.TotalAmount += item.Nilai
}
if row.QtyIn > 0 {
row.UnitPrice = row.TotalAmount / row.QtyIn
}
}
for i := range target.Rows {
target.Rows[i].ID = i + 1
}
}
buildTotals := func(cat *SapronakCategoryDTO, label string) {
if cat == nil {
return
}
var qtyIn, qtyOut, qtyUsed, total float64
for _, r := range cat.Rows {
qtyIn += r.QtyIn
qtyOut += r.QtyOut
qtyUsed += r.QtyUsed
total += r.TotalAmount
}
avg := 0.0
if qtyIn > 0 {
avg = total / qtyIn
}
cat.Total = SapronakCategoryTotalDTO{
Label: label,
QtyIn: qtyIn,
QtyOut: qtyOut,
QtyUsed: qtyUsed,
AvgUnitPrice: avg,
TotalAmount: total,
}
}
buildTotals(result.Doc, "TOTAL DOC")
buildTotals(result.Ovk, "TOTAL OVK")
buildTotals(result.Pakan, "TOTAL PAKAN")
buildTotals(result.Pullet, "TOTAL PULLET")
return result
}
+22 -3
View File
@@ -5,8 +5,16 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
rExpenseRealization "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
@@ -17,10 +25,21 @@ type ClosingModule struct{}
func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
closingRepo := rClosing.NewClosingRepository(db) closingRepo := rClosing.NewClosingRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db)
marketingRepo := rMarketings.NewMarketingRepository(db)
marketingDeliveryProductRepo := rMarketings.NewMarketingDeliveryProductRepository(db)
expenseRealizationRepo := rExpenseRealization.NewExpenseRealizationRepository(db)
chickinRepo := rChickin.NewChickinRepository(db)
recordingRepo := rRecording.NewRecordingRepository(db)
purchaseRepo := rPurchase.NewPurchaseRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
closingService := sClosing.NewClosingService(closingRepo, validate) closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, validate)
sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ClosingRoutes(router, userService, closingService) ClosingRoutes(router, userService, closingService, sapronakService)
} }
File diff suppressed because it is too large Load Diff
+15 -5
View File
@@ -1,7 +1,7 @@
package closings package closings
import ( import (
// m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/controllers" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/controllers"
closing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" closing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
@@ -9,10 +9,11 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService) { func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService) {
ctrl := controller.NewClosingController(s) ctrl := controller.NewClosingController(s, sapronakSvc)
route := v1.Group("/closings") route := v1.Group("/closings")
route.Use(m.Auth(u))
// route.Get("/", m.Auth(u), ctrl.GetAll) // route.Get("/", m.Auth(u), ctrl.GetAll)
// route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Post("/", m.Auth(u), ctrl.CreateOne)
@@ -20,6 +21,15 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService
// route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne)
// route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne)
route.Get("/", ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_ClosingGetAll), ctrl.GetAll)
route.Get("/:id", ctrl.GetOne) route.Get("/:project_flock_id/penjualan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetPenjualan)
route.Get("/:projectFlockId", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSummary)
route.Get("/:project_flock_id/overhead", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetOverhead)
route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang)
route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByProject)
route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak)
route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPP)
route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang)
route.Get("/:projectFlockId/data-produksi", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi)
route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan)
} }
@@ -1,12 +1,27 @@
package service package service
import ( import (
"context"
"errors" "errors"
"math"
"strconv"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
marketingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -15,21 +30,46 @@ import (
) )
type ClosingService interface { type ClosingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
GetPenjualan(ctx *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error)
GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error)
GetOverhead(ctx *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error)
GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error)
GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error)
GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error)
GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error)
} }
type closingService struct { type closingService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
Repository repository.ClosingRepository Repository repository.ClosingRepository
ProjectFlockRepo projectflockRepository.ProjectflockRepository
MarketingRepo marketingRepository.MarketingRepository
MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository
ApprovalSvc commonSvc.ApprovalService
ExpenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository
ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository
ChickinRepo chickinRepository.ProjectChickinRepository
PurchaseRepo purchaseRepository.PurchaseRepository
RecordingRepo recordingRepository.RecordingRepository
} }
func NewClosingService(repo repository.ClosingRepository, validate *validator.Validate) ClosingService { func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, purchaseRepo purchaseRepository.PurchaseRepository, recordingRepo recordingRepository.RecordingRepository, validate *validator.Validate) ClosingService {
return &closingService{ return &closingService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
ProjectFlockRepo: projectFlockRepo,
MarketingRepo: marketingRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
ApprovalSvc: approvalSvc,
ExpenseRealizationRepo: expenseRealizationRepo,
ProjectBudgetRepo: projectBudgetRepo,
ChickinRepo: chickinRepo,
PurchaseRepo: purchaseRepo,
RecordingRepo: recordingRepo,
} }
} }
@@ -37,7 +77,14 @@ func (s closingService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser") return db.Preload("CreatedUser")
} }
func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { func (s closingService) withClosingRelations(db *gorm.DB) *gorm.DB {
return s.withRelations(db).
Preload("Location").
Preload("KandangHistory").
Preload("KandangHistory.Chickins")
}
func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -45,9 +92,9 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withClosingRelations(db)
if params.Search != "" { if params.Search != "" {
return db.Where("name LIKE ?", "%"+params.Search+"%") return db.Where("flock_name LIKE ?", "%"+params.Search+"%")
} }
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
@@ -56,17 +103,740 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
s.Log.Errorf("Failed to get closings: %+v", err) s.Log.Errorf("Failed to get closings: %+v", err)
return nil, 0, err return nil, 0, err
} }
return closings, total, nil
result := make([]dto.ClosingListItemDTO, 0, len(closings))
for _, closing := range closings {
statusProject, _, err := s.getApprovalStatuses(c.Context(), closing.Id)
if err != nil {
s.Log.Errorf("Failed to retrieve approval statuses for project flock %d: %+v", closing.Id, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval status")
}
result = append(result, dto.ToClosingListItemDTO(closing, statusProject))
}
return result, total, nil
} }
func (s closingService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) {
closing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Closing not found") return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
} }
if err != nil { if err != nil {
s.Log.Errorf("Failed get closing by id: %+v", err)
return nil, err return nil, err
} }
return closing, nil return projectFlock, nil
}
func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) {
realisasi, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB {
return db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Uom").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.Warehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Preload("MarketingProduct.Marketing").
Preload("MarketingProduct.Marketing.Customer").
Order("marketing_delivery_products.delivery_date DESC")
})
if err != nil {
return nil, err
}
if len(realisasi) == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Penjualan realisasi not found")
}
return realisasi, nil
}
func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withClosingRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found")
}
if err != nil {
s.Log.Errorf("Failed get project flock %d for closing summary: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
statusProject, statusClosing, err := s.getApprovalStatuses(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to retrieve approval statuses for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval status")
}
summary := dto.ToClosingSummaryDTO(*project, statusProject, statusClosing)
return &summary, nil
}
func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) {
if projectFlockID == 0 {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if params == nil {
params = &validation.ClosingSapronakQuery{}
}
if params.Page == 0 {
params.Page = 1
}
if params.Limit == 0 {
params.Limit = 10
}
if err := s.Validate.Struct(params); err != nil {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if params.Type != validation.SapronakTypeIncoming && params.Type != validation.SapronakTypeOutgoing {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
}
if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan")
}
s.Log.Errorf("Failed get project flock %d for sapronak closing: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock")
}
var projectFlockKandangIDs []uint
if params.Type == validation.SapronakTypeOutgoing {
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang")
}
}
offset := (params.Page - 1) * params.Limit
rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{
Type: params.Type,
WarehouseIDs: warehouseIDs,
ProjectFlockKandangIDs: projectFlockKandangIDs,
Limit: params.Limit,
Offset: offset,
})
if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sapronak data")
}
items := make([]dto.ClosingSapronakItemDTO, 0, len(rows))
for _, row := range rows {
dateStr := row.DateText
if dateStr == "" && !row.SortDate.IsZero() {
dateStr = row.SortDate.Format("02-Jan-2006")
}
items = append(items, dto.ClosingSapronakItemDTO{
Id: row.Id,
Date: dateStr,
ReferenceNumber: row.ReferenceNumber,
TransactionType: row.TransactionType,
ProductName: row.ProductName,
ProductCategory: row.ProductCategory,
ProductSubCategory: row.ProductSubCategory,
SourceWarehouse: row.SourceWarehouse,
DestinationWarehouse: row.DestinationWarehouse,
// Destination: row.Destination,
Quantity: row.Quantity,
Unit: row.Unit,
FormattedQuantity: formatQuantity(row.Quantity, row.Unit),
Notes: row.Notes,
SortDate: row.SortDate,
})
}
return items, totalResults, nil
}
func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) {
var kandangIDs []uint
db := s.Repository.DB().WithContext(ctx)
if err := db.Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ?", projectFlockID).
Pluck("kandang_id", &kandangIDs).Error; err != nil {
return nil, err
}
if len(kandangIDs) == 0 {
return []uint{}, nil
}
var warehouses []entity.Warehouse
if err := db.Where("kandang_id IN ?", kandangIDs).Find(&warehouses).Error; err != nil {
return nil, err
}
unique := make(map[uint]struct{})
for _, warehouse := range warehouses {
unique[warehouse.Id] = struct{}{}
}
ids := make([]uint, 0, len(unique))
for id := range unique {
ids = append(ids, id)
}
return ids, nil
}
func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint) ([]uint, error) {
var ids []uint
err := s.Repository.DB().WithContext(ctx).
Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ?", projectFlockID).
Pluck("id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func formatQuantity(qty float64, uom string) string {
qtyStr := strconv.FormatFloat(qty, 'f', -1, 64)
if uom == "" {
return qtyStr
}
return qtyStr + " " + uom
}
func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID uint) (string, string, error) {
if s.ApprovalSvc == nil {
return "", "Belum Selesai", nil
}
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "")
if err != nil {
return "", "", err
}
var (
minStep uint16
statusProject string
completed int
latestActionAt time.Time
)
for _, rec := range records {
if minStep == 0 || rec.StepNumber < minStep {
minStep = rec.StepNumber
}
if latestActionAt.IsZero() || rec.ActionAt.After(latestActionAt) {
latestActionAt = rec.ActionAt
statusProject = rec.StepName
}
}
if statusProject == "" && minStep > 0 {
if label, ok := approvalutils.ApprovalStepName(utils.ApprovalWorkflowProjectFlock, approvalutils.ApprovalStep(minStep)); ok {
statusProject = label
}
}
statusClosing := "Belum Selesai"
switch {
case len(records) == 0 || completed == 0:
statusClosing = "Belum Selesai"
case completed < len(records):
statusClosing = "Sebagian"
default:
statusClosing = "Selesai"
}
return statusProject, statusClosing, nil
}
func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) {
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, err
}
realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, err
}
chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, err
}
var totalChickinQty float64
for _, chickin := range chickins {
totalChickinQty += chickin.UsageQty
}
totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err)
}
totalActualPopulation := totalChickinQty - totalDepletion
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation)
return &result, nil
}
func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: func(ctx context.Context, id uint) (bool, error) {
_, err := s.ProjectFlockRepo.GetByID(ctx, id, nil)
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return err == nil, err
}},
); err != nil {
return nil, err
}
projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
// Get actual usage cost instead of purchase items
actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost")
}
// Convert actual usage rows to pseudo purchase items
purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows)
realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations")
}
deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB {
return db.Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product")
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products")
}
chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins")
}
totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err)
}
totalEggWeightKg, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
s.Log.Warnf("GetTotalEggProductionWeightByProjectFlockID error: %v", err)
}
totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err)
}
input := dto.ClosingKeuanganInput{
ProjectFlockCategory: projectFlock.Category,
PurchaseItems: purchaseItems,
Budgets: budgets,
Realizations: realizations,
DeliveryProducts: deliveryProducts,
Chickins: chickins,
TotalWeightProduced: totalWeightProduced,
TotalEggWeightKg: totalEggWeightKg,
TotalDepletion: totalDepletion,
}
report := dto.ToClosingKeuanganReport(input)
return &report, nil
}
func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
rows, err := s.Repository.GetExpeditionHPP(c.Context(), projectFlockID, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to get expedition HPP for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch expedition HPP")
}
expeditionCosts := make([]dto.ExpeditionCostItemDTO, 0, len(rows))
var totalHPP float64
for idx, row := range rows {
expeditionCosts = append(expeditionCosts, dto.ExpeditionCostItemDTO{
Id: uint64(idx + 1),
ExpeditionVendorName: row.SupplierName,
HPPAmount: row.TotalAmount,
})
totalHPP += row.TotalAmount
}
result := &dto.ExpeditionHPPDTO{
ExpeditionCosts: expeditionCosts,
TotalHPPAmount: totalHPP,
}
return result, nil
}
func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withClosingRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found")
}
if err != nil {
s.Log.Errorf("Failed get project flock %d for closing data produksi: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
var population float64
for _, history := range project.KandangHistory {
for _, chickin := range history.Chickins {
population += chickin.UsageQty + chickin.PendingUsageQty
}
}
isGrowing := strings.EqualFold(project.Category, string(utils.ProjectFlockCategoryGrowing))
projectFlockKandangIDs, err := s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs")
}
feedIn, feedUsed, err := s.Repository.SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c.Context(), projectFlockKandangIDs)
if err != nil {
s.Log.Errorf("Failed to sum feed purchase/used qty for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch feed purchase data")
}
claimCulling, err := s.Repository.SumClaimCullingByProjectFlockKandangIDs(c.Context(), projectFlockKandangIDs)
if err != nil {
s.Log.Errorf("Failed to sum claim culling for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch claim culling data")
}
finalPopulation := population - claimCulling
var standards []entity.FcrStandard
if project.FcrId > 0 {
standards, err = s.Repository.GetFcrStandardsByFcrID(c.Context(), project.FcrId)
if err != nil {
s.Log.Errorf("Failed to fetch FCR standards for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data")
}
}
age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data")
}
feedUsedPerHead := 0.0
if population > 0 {
feedUsedPerHead = feedUsed / population
}
purchase := dto.ClosingPurchaseDTO{
InitialPopulation: int(population),
ClaimCulling: int(claimCulling),
FinalPopulation: int(finalPopulation),
FeedIn: feedIn,
FeedUsed: feedUsed,
FeedUsedPerHead: feedUsedPerHead,
}
chickenFlagNames := []string{string(utils.FlagPullet)}
chickenSalesWeight, chickenSalesQty, chickenSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, chickenFlagNames)
if err != nil {
s.Log.Errorf("Failed to fetch chicken sales data for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chicken sales data")
}
var chickenAverageWeight float64
if chickenSalesQty > 0 {
chickenAverageWeight = chickenSalesWeight / chickenSalesQty
}
var chickenAverageSellingPrice float64
if chickenSalesWeight > 0 {
chickenAverageSellingPrice = chickenSalesPrice / chickenSalesWeight
}
chickenSales := dto.ClosingSalesDTO{
SalesPopulation: int(chickenSalesQty),
SalesWeight: chickenSalesWeight,
AverageWeight: chickenAverageWeight,
AverageSellingPrice: chickenAverageSellingPrice,
}
chickenDepletion := population - chickenSalesQty
if chickenDepletion < 0 {
chickenDepletion = 0
}
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age, standards)
var eggSales *dto.ClosingEggSalesDTO
var eggPerformance *dto.ClosingPerformanceDTO
if !isGrowing {
eggFlagNames := []string{
string(utils.FlagTelur),
string(utils.FlagTelurUtuh),
string(utils.FlagTelurPecah),
string(utils.FlagTelurPutih),
string(utils.FlagTelurRetak),
}
eggSalesWeight, eggSalesQty, eggSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, eggFlagNames)
if err != nil {
s.Log.Errorf("Failed to fetch egg sales data for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch egg sales data")
}
var averageEggWeight float64
if eggSalesQty > 0 {
averageEggWeight = eggSalesWeight / eggSalesQty
}
var averageEggSellingPrice float64
if eggSalesWeight > 0 {
averageEggSellingPrice = eggSalesPrice / eggSalesWeight
}
eggSales = &dto.ClosingEggSalesDTO{
EggPieces: int(eggSalesQty),
EggMassKg: eggSalesWeight,
AverageEggWeightKg: averageEggWeight,
AverageSellingPrice: averageEggSellingPrice,
}
harvestEggQty, err := s.Repository.SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, eggFlagNames)
if err != nil {
s.Log.Errorf("Failed to fetch recording egg qty for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch egg harvest data")
}
eggDepletion := harvestEggQty - eggSalesQty
if eggDepletion < 0 {
eggDepletion = 0
}
eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age, standards)
eggPerformance = &eggPerf
}
sales := dto.ClosingSalesGroupDTO{
Chicken: chickenSales,
Egg: eggSales,
}
performance := dto.ClosingPerformanceDTO{
Depletion: chickenPerformance.Depletion,
Age: age,
MortalityStd: chickenPerformance.MortalityStd,
MortalityAct: chickenPerformance.MortalityAct,
DeffMortality: chickenPerformance.DeffMortality,
}
if eggPerformance != nil {
performance.FcrStd = eggPerformance.FcrStd
performance.FcrAct = eggPerformance.FcrAct
performance.DeffFcr = eggPerformance.DeffFcr
performance.Awg = eggPerformance.Awg
} else {
performance.FcrStd = chickenPerformance.FcrStd
performance.FcrAct = chickenPerformance.FcrAct
performance.DeffFcr = chickenPerformance.DeffFcr
performance.Awg = chickenPerformance.Awg
}
result := dto.ClosingProductionReportDTO{
Purchase: purchase,
Sales: sales,
Performance: performance,
}
return &result, nil
}
func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint) (float64, error) {
deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(ctx, projectFlockID, func(db *gorm.DB) *gorm.DB {
return db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins")
})
if err != nil {
return 0, err
}
var (
totalQty float64
totalAgeWeeks float64
)
for _, product := range deliveryProducts {
if product.UsageQty == 0 {
continue
}
projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang
ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate)
totalAgeWeeks += float64(ageWeeks) * product.UsageQty
totalQty += product.UsageQty
}
if totalQty == 0 {
return 0, nil
}
return totalAgeWeeks / totalQty, nil
}
func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO {
mortalityStd, fcrStd := closestFcrValues(standards, averageWeight)
fcrAct := 0.0
if totalWeight > 0 {
fcrAct = feedUsed / totalWeight
}
mortalityAct := 0.0
if basePopulation > 0 {
mortalityAct = (depletion / basePopulation) * 100
}
deffMortality := mortalityAct - mortalityStd
deffFcr := fcrAct - fcrStd
awg := 0.0
if age > 0 {
awg = averageWeight / age
}
return dto.ClosingPerformanceDTO{
Depletion: depletion,
Age: age,
MortalityStd: mortalityStd,
MortalityAct: mortalityAct,
DeffMortality: deffMortality,
FcrStd: fcrStd,
FcrAct: fcrAct,
DeffFcr: deffFcr,
Awg: awg,
}
}
func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (float64, float64) {
if len(standards) == 0 || averageWeight <= 0 {
return 0, 0
}
closest := standards[0]
minDiff := math.Abs(closest.Weight - averageWeight)
for _, std := range standards[1:] {
diff := math.Abs(std.Weight - averageWeight)
if diff < minDiff {
minDiff = diff
closest = std
}
}
return closest.Mortality, closest.FcrNumber
}
func (s closingService) convertActualUsageToPurchaseItems(ctx context.Context, actualUsageRows []repository.ActualUsageCostRow) []entity.PurchaseItem {
if len(actualUsageRows) == 0 {
return []entity.PurchaseItem{}
}
// Collect all product IDs
productIDs := make([]uint, len(actualUsageRows))
for i, row := range actualUsageRows {
productIDs[i] = row.ProductID
}
// Fetch products with flags from repository
products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, productIDs)
if err != nil {
s.Log.Warnf("Failed to fetch products for actual usage: %v", err)
products = []entity.Product{}
}
// Create product map
productMap := make(map[uint]*entity.Product)
for i := range products {
productMap[products[i].Id] = &products[i]
}
// Convert to pseudo purchase items
purchaseItems := make([]entity.PurchaseItem, 0, len(actualUsageRows))
for _, row := range actualUsageRows {
product := productMap[row.ProductID]
// Skip if product not found
if product == nil {
s.Log.Warnf("Product ID %d not found for actual usage", row.ProductID)
continue
}
purchaseItem := entity.PurchaseItem{
Id: 0, // Pseudo item, no ID
ProductId: row.ProductID,
TotalQty: row.TotalQty,
TotalPrice: row.TotalPrice,
Price: row.AveragePrice,
Product: product,
}
purchaseItems = append(purchaseItems, purchaseItem)
}
return purchaseItems
} }
@@ -0,0 +1,681 @@
package service
import (
"context"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
type SapronakService interface {
GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error)
GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error)
}
type sapronakService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.ClosingRepository
ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository
}
func NewSapronakService(
repo repository.ClosingRepository,
pfkRepo projectflockRepository.ProjectFlockKandangRepository,
validate *validator.Validate,
) SapronakService {
return &sapronakService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ProjectFlockKandangRepo: pfkRepo,
}
}
func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required")
}
reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
ProjectFlockID: projectFlockID,
Status: "all",
Flag: flag,
})
if err != nil {
return nil, err
}
if len(reports) <= 1 {
return reports, nil
}
combined := s.combineSapronakReports(reports, projectFlockID)
return []dto.SapronakReportDTO{combined}, nil
}
func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) {
if projectFlockID == 0 || pfkID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required")
}
results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
ProjectFlockID: projectFlockID,
ProjectFlockKandangID: pfkID,
Status: "all",
Flag: flag,
})
if err != nil {
return nil, err
}
for _, res := range results {
if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID {
return &res, nil
}
}
return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found")
}
func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) {
pfks, err := s.loadProjectFlockKandangs(ctx, params)
if err != nil {
return nil, err
}
if len(pfks) == 0 {
return []dto.SapronakReportDTO{}, nil
}
filterStatus := strings.ToLower(strings.TrimSpace(params.Status))
if filterStatus == "" {
filterStatus = "all"
}
results := make([]dto.SapronakReportDTO, 0, len(pfks))
for _, pfk := range pfks {
status := "closing"
if pfk.ClosedAt == nil {
status = "active"
}
if (filterStatus == "active" && status != "active") || (filterStatus == "closing" && status != "closing") {
continue
}
// We no longer filter by date for closing sapronak report; pass nil pointers.
items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, nil, nil, params.Flag)
if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report")
}
results = append(results, dto.SapronakReportDTO{
ProjectFlockKandangID: pfk.Id,
ProjectFlockID: pfk.ProjectFlockId,
ProjectName: pfk.ProjectFlock.FlockName,
KandangID: pfk.KandangId,
KandangName: pfk.Kandang.Name,
Period: pfk.Period,
Status: status,
StartDate: nil,
EndDate: nil,
TotalIncomingValue: totalIncoming,
TotalUsageValue: totalUsage,
Items: items,
Groups: groups,
})
}
return results, nil
}
func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) {
db := s.ProjectFlockKandangRepo.DB().WithContext(ctx).
Preload("ProjectFlock").
Preload("Kandang").
Preload("Chickins")
if params != nil {
if params.ProjectFlockID > 0 {
db = db.Where("project_flock_kandangs.project_flock_id = ?", params.ProjectFlockID)
}
if params.KandangID > 0 {
db = db.Where("project_flock_kandangs.kandang_id = ?", params.KandangID)
}
if params.ProjectFlockKandangID > 0 {
db = db.Where("project_flock_kandangs.id = ?", params.ProjectFlockKandangID)
}
}
var pfks []entity.ProjectFlockKandang
if err := db.Find(&pfks).Error; err != nil {
s.Log.Errorf("Failed to load project flock kandangs for sapronak report: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load project flock kandangs")
}
return pfks, nil
}
func (s sapronakService) combineSapronakReports(reports []dto.SapronakReportDTO, projectID uint) dto.SapronakReportDTO {
if len(reports) == 0 {
return dto.SapronakReportDTO{}
}
var (
totalIncoming float64
totalUsage float64
projectName = reports[0].ProjectName
)
itemMap := make(map[uint]dto.SapronakItemDTO)
groupMap := make(map[string]*dto.SapronakGroupDTO)
ensureGroup := func(flag string) *dto.SapronakGroupDTO {
if g, ok := groupMap[flag]; ok {
return g
}
groupMap[flag] = &dto.SapronakGroupDTO{Flag: flag}
return groupMap[flag]
}
for _, r := range reports {
totalIncoming += r.TotalIncomingValue
totalUsage += r.TotalUsageValue
for _, it := range r.Items {
cur := itemMap[it.ProductID]
if cur.ProductID == 0 {
cur.ProductID = it.ProductID
cur.ProductName = it.ProductName
cur.Flag = it.Flag
}
cur.IncomingQty += it.IncomingQty
cur.IncomingValue += it.IncomingValue
cur.UsageQty += it.UsageQty
cur.UsageValue += it.UsageValue
if cur.IncomingQty >= cur.UsageQty {
cur.RemainingQty = cur.IncomingQty - cur.UsageQty
} else {
cur.RemainingQty = 0
}
if cur.IncomingQty > 0 {
cur.AveragePrice = cur.IncomingValue / cur.IncomingQty
} else {
cur.AveragePrice = it.AveragePrice
}
itemMap[it.ProductID] = cur
}
for _, g := range r.Groups {
agg := ensureGroup(g.Flag)
agg.TotalMasuk += g.TotalMasuk
agg.TotalKeluar += g.TotalKeluar
agg.SaldoAkhir += g.SaldoAkhir
agg.TotalNilai += g.TotalNilai
agg.Items = append(agg.Items, g.Items...)
}
}
items := make([]dto.SapronakItemDTO, 0, len(itemMap))
for _, it := range itemMap {
items = append(items, it)
}
groups := make([]dto.SapronakGroupDTO, 0, len(groupMap))
for _, g := range groupMap {
groups = append(groups, *g)
}
return dto.SapronakReportDTO{
ProjectFlockID: projectID,
ProjectName: projectName,
Status: "combined",
StartDate: nil,
TotalIncomingValue: totalIncoming,
TotalUsageValue: totalUsage,
Items: items,
Groups: groups,
}
}
func mapIncomingUsage(incomingRows []repository.SapronakIncomingRow, usageRows []repository.SapronakUsageRow) (map[uint]repository.SapronakIncomingRow, map[uint]repository.SapronakUsageRow) {
incoming := make(map[uint]repository.SapronakIncomingRow, len(incomingRows))
for _, row := range incomingRows {
incoming[row.ProductID] = row
}
usage := make(map[uint]repository.SapronakUsageRow, len(usageRows))
for _, row := range usageRows {
usage[row.ProductID] = row
}
return incoming, usage
}
type sapronakDetailMaps struct {
Incoming map[uint][]dto.SapronakDetailDTO
Usage map[uint][]dto.SapronakDetailDTO
AdjIncoming map[uint][]dto.SapronakDetailDTO
AdjOutgoing map[uint][]dto.SapronakDetailDTO
TransferIn map[uint][]dto.SapronakDetailDTO
TransferOut map[uint][]dto.SapronakDetailDTO
}
func buildSapronakDetails(
incomingRows map[uint][]repository.SapronakDetailRow,
usageRows map[uint][]repository.SapronakDetailRow,
adjIncomingRows map[uint][]repository.SapronakDetailRow,
adjOutgoingRows map[uint][]repository.SapronakDetailRow,
transferInRows map[uint][]repository.SapronakDetailRow,
transferOutRows map[uint][]repository.SapronakDetailRow,
) sapronakDetailMaps {
result := sapronakDetailMaps{
Incoming: make(map[uint][]dto.SapronakDetailDTO),
Usage: make(map[uint][]dto.SapronakDetailDTO),
AdjIncoming: make(map[uint][]dto.SapronakDetailDTO),
AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO),
TransferIn: make(map[uint][]dto.SapronakDetailDTO),
TransferOut: make(map[uint][]dto.SapronakDetailDTO),
}
addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) {
for pid, rows := range src {
for _, r := range rows {
d := dto.SapronakDetailDTO{
ProductID: r.ProductID,
ProductName: r.ProductName,
Flag: r.Flag,
Tanggal: r.Date,
NoReferensi: r.Reference,
JenisTransaksi: jenis,
Harga: r.Price,
}
if masuk {
d.QtyMasuk = r.QtyIn
d.Nilai = r.QtyIn * r.Price
} else {
d.QtyKeluar = r.QtyOut
d.Nilai = r.QtyOut * r.Price
}
target[pid] = append(target[pid], d)
}
}
}
addRows(result.Incoming, incomingRows, "Pembelian", true)
addRows(result.Usage, usageRows, "Pemakaian", false)
addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true)
addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false)
addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true)
addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false)
return result
}
func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) {
// For sapronak closing report we intentionally ignore date range
// and aggregate all historical transactions for the kandang/project.
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId)
if err != nil {
return nil, nil, 0, 0, err
}
incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId)
if err != nil {
return nil, nil, 0, 0, err
}
usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId)
if err != nil {
return nil, nil, 0, 0, err
}
transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId)
if err != nil {
return nil, nil, 0, 0, err
}
filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter))
matchesFlag := func(f string) bool {
if filterFlag == "" {
return true
}
return strings.ToUpper(f) == filterFlag
}
// For project flocks with category GROWING, pullet usage from chickin
// should not be counted yet. Only when category is LAYING we allow
// pullet usage to contribute to qty_used.
isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying))
if !isLaying {
filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows))
for _, row := range chickinUsageRows {
if strings.ToUpper(row.Flag) == "DOC" {
filteredUsage = append(filteredUsage, row)
}
}
chickinUsageRows = filteredUsage
filteredDetail := make(map[uint][]repository.SapronakDetailRow, len(chickinUsageDetailsRows))
for pid, rows := range chickinUsageDetailsRows {
for _, d := range rows {
if strings.ToUpper(d.Flag) == "DOC" {
filteredDetail[pid] = append(filteredDetail[pid], d)
}
}
}
chickinUsageDetailsRows = filteredDetail
}
allUsageRows := append(usageRows, chickinUsageRows...)
incoming, usage := mapIncomingUsage(incomingRows, allUsageRows)
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
groupMap := make(map[string]*dto.SapronakGroupDTO)
for pid, rows := range chickinUsageDetailsRows {
if len(rows) == 0 {
continue
}
usageDetailsRows[pid] = append(usageDetailsRows[pid], rows...)
}
detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows)
incomingDetails := detailMaps.Incoming
usageDetails := detailMaps.Usage
adjIncoming := detailMaps.AdjIncoming
adjOutgoing := detailMaps.AdjOutgoing
transIncoming := detailMaps.TransferIn
transOutgoing := detailMaps.TransferOut
ensureGroup := func(flag string) *dto.SapronakGroupDTO {
if g, ok := groupMap[flag]; ok {
return g
}
groupMap[flag] = &dto.SapronakGroupDTO{Flag: flag}
return groupMap[flag]
}
for _, row := range incoming {
if !matchesFlag(row.Flag) {
continue
}
avgPrice := row.DefaultPrice
if row.Qty > 0 && row.Value > 0 {
avgPrice = row.Value / row.Qty
}
itemMap[row.ProductID] = dto.SapronakItemDTO{
ProductID: row.ProductID,
ProductName: row.ProductName,
Flag: row.Flag,
IncomingQty: row.Qty,
IncomingValue: row.Value,
RemainingQty: row.Qty,
AveragePrice: avgPrice,
}
}
for _, row := range usage {
if !matchesFlag(row.Flag) {
continue
}
existing := itemMap[row.ProductID]
price := existing.AveragePrice
if price == 0 {
price = row.DefaultPrice
}
usageValue := row.Qty * price
existing.ProductID = row.ProductID
if existing.ProductName == "" {
existing.ProductName = row.ProductName
}
if existing.Flag == "" {
existing.Flag = row.Flag
}
existing.AveragePrice = price
existing.UsageQty += row.Qty
existing.UsageValue += usageValue
if existing.IncomingQty >= existing.UsageQty {
existing.RemainingQty = existing.IncomingQty - existing.UsageQty
} else {
existing.RemainingQty = 0
}
itemMap[row.ProductID] = existing
}
for productID, details := range adjIncoming {
for _, d := range details {
if !matchesFlag(d.Flag) {
continue
}
existing := itemMap[productID]
if existing.Flag == "" {
existing.Flag = d.Flag
}
if existing.ProductName == "" {
existing.ProductName = d.ProductName
}
existing.IncomingQty += d.QtyMasuk
existing.IncomingValue += d.Nilai
if existing.IncomingQty > 0 {
existing.AveragePrice = existing.IncomingValue / existing.IncomingQty
}
if existing.IncomingQty >= existing.UsageQty {
existing.RemainingQty = existing.IncomingQty - existing.UsageQty
} else {
existing.RemainingQty = 0
}
itemMap[productID] = existing
}
}
for productID, details := range adjOutgoing {
for _, d := range details {
if !matchesFlag(d.Flag) {
continue
}
existing := itemMap[productID]
if existing.Flag == "" {
existing.Flag = d.Flag
}
if existing.ProductName == "" {
existing.ProductName = d.ProductName
}
existing.UsageQty += d.QtyKeluar
existing.UsageValue += d.Nilai
if existing.IncomingQty >= existing.UsageQty {
existing.RemainingQty = existing.IncomingQty - existing.UsageQty
} else {
existing.RemainingQty = 0
}
itemMap[productID] = existing
}
}
for productID, details := range transIncoming {
for _, d := range details {
if !matchesFlag(d.Flag) {
continue
}
existing := itemMap[productID]
if existing.Flag == "" {
existing.Flag = d.Flag
}
if existing.ProductName == "" {
existing.ProductName = d.ProductName
}
existing.IncomingQty += d.QtyMasuk
existing.IncomingValue += d.Nilai
if existing.IncomingQty > 0 {
existing.AveragePrice = existing.IncomingValue / existing.IncomingQty
}
if existing.IncomingQty >= existing.UsageQty {
existing.RemainingQty = existing.IncomingQty - existing.UsageQty
} else {
existing.RemainingQty = 0
}
itemMap[productID] = existing
}
}
items := make([]dto.SapronakItemDTO, 0, len(itemMap))
var totalIncoming, totalUsage float64
for _, item := range itemMap {
totalIncoming += item.IncomingValue
totalUsage += item.UsageValue
items = append(items, item)
}
for productID, details := range incomingDetails {
flag := ""
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) {
continue
}
group := ensureGroup(flag)
for _, d := range details {
d.Flag = flag
d.ProductName = name
group.Items = append(group.Items, d)
group.TotalMasuk += d.QtyMasuk
group.TotalNilai += d.Nilai
group.SaldoAkhir += d.QtyMasuk
}
}
for productID, details := range adjIncoming {
flag := ""
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) {
continue
}
group := ensureGroup(flag)
for _, d := range details {
d.Flag = flag
d.ProductName = name
group.Items = append(group.Items, d)
group.TotalMasuk += d.QtyMasuk
group.TotalNilai += d.Nilai
group.SaldoAkhir += d.QtyMasuk
}
}
for productID, details := range usageDetails {
flag := ""
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) {
continue
}
group := ensureGroup(flag)
for _, d := range details {
d.Flag = flag
d.ProductName = name
group.Items = append(group.Items, d)
group.TotalKeluar += d.QtyKeluar
group.SaldoAkhir -= d.QtyKeluar
}
}
for productID, details := range adjOutgoing {
flag := ""
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) {
continue
}
group := ensureGroup(flag)
for _, d := range details {
d.Flag = flag
d.ProductName = name
group.Items = append(group.Items, d)
group.TotalKeluar += d.QtyKeluar
group.SaldoAkhir -= d.QtyKeluar
}
}
for productID, details := range transIncoming {
flag := ""
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) {
continue
}
group := ensureGroup(flag)
for _, d := range details {
d.Flag = flag
d.ProductName = name
group.Items = append(group.Items, d)
group.TotalMasuk += d.QtyMasuk
group.TotalNilai += d.Nilai
group.SaldoAkhir += d.QtyMasuk
}
}
for productID, details := range transOutgoing {
flag := ""
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) {
continue
}
group := ensureGroup(flag)
for _, d := range details {
d.Flag = flag
d.ProductName = name
group.Items = append(group.Items, d)
group.TotalKeluar += d.QtyKeluar
group.SaldoAkhir -= d.QtyKeluar
}
}
groups := make([]dto.SapronakGroupDTO, 0, len(groupMap))
for _, g := range groupMap {
groups = append(groups, *g)
}
return items, groups, totalIncoming, totalUsage, nil
}
@@ -1,11 +1,11 @@
package validation package validation
type Create struct { type Create struct {
Name string `json:"name" validate:"required_strict,min=3"` Name string `json:"name" validate:"required_strict,min=3"`
} }
type Update struct { type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"` Name *string `json:"name,omitempty" validate:"omitempty"`
} }
type Query struct { type Query struct {
@@ -13,3 +13,14 @@ type Query struct {
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
} }
const (
SapronakTypeIncoming = "incoming"
SapronakTypeOutgoing = "outgoing"
)
type ClosingSapronakQuery struct {
Type string `query:"type" validate:"required,oneof=incoming outgoing"`
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
}
@@ -0,0 +1,9 @@
package validation
type CountSapronakQuery struct {
ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
KandangID uint `query:"kandang_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
Status string `query:"status" validate:"omitempty,oneof=active closing all"`
Flag string `query:"flag" validate:"omitempty,oneof=DOC OVK PAKAN PULLET doc ovk pakan pullet"`
}
-1
View File
@@ -12,6 +12,5 @@ func ConstantRoutes(v1 fiber.Router, s constant.ConstantService) {
ctrl := controller.NewConstantController(s) ctrl := controller.NewConstantController(s)
route := v1.Group("/constants") route := v1.Group("/constants")
route.Get("/", ctrl.GetAll) route.Get("/", ctrl.GetAll)
} }
@@ -90,6 +90,12 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error {
} }
req.SupplierID = supplierID req.SupplierID = supplierID
locationID, err := strconv.ParseUint(c.FormValue("location_id"), 10, 64)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format")
}
req.LocationID = locationID
form, err := c.MultipartForm() form, err := c.MultipartForm()
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form")
@@ -106,17 +112,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err))
} }
if singleExpenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required")
}
req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock} req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock}
} else {
for i, expenseNonstock := range req.ExpenseNonstocks {
if expenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i))
}
}
} }
} else { } else {
return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required") return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required")
@@ -151,12 +147,16 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
} }
req.Documents = form.File["documents"] req.Documents = form.File["documents"]
if transactionDate := c.FormValue("transaction_date"); transactionDate != "" {
transactionDate := c.FormValue("transaction_date")
if transactionDate != "" {
req.TransactionDate = &transactionDate req.TransactionDate = &transactionDate
} }
categoryVal := c.FormValue("category") categoryVal := c.FormValue("category")
req.Category = &categoryVal if categoryVal != "" {
req.Category = &categoryVal
}
supplierIDVal := c.FormValue("supplier_id") supplierIDVal := c.FormValue("supplier_id")
if supplierIDVal != "" { if supplierIDVal != "" {
@@ -167,6 +167,15 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
req.SupplierID = &supplierID req.SupplierID = &supplierID
} }
locationIDVal := c.FormValue("location_id")
if locationIDVal != "" {
locationID, err := strconv.ParseUint(locationIDVal, 10, 64)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format")
}
req.LocationID = &locationID
}
expenseNonstocksJSON := c.FormValue("expense_nonstocks") expenseNonstocksJSON := c.FormValue("expense_nonstocks")
if expenseNonstocksJSON != "" { if expenseNonstocksJSON != "" {
var expenseNonstocks []validation.ExpenseNonstock var expenseNonstocks []validation.ExpenseNonstock
@@ -174,12 +183,6 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err))
} }
for i, expenseNonstock := range expenseNonstocks {
if expenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i))
}
}
req.ExpenseNonstocks = &expenseNonstocks req.ExpenseNonstocks = &expenseNonstocks
} }
@@ -312,13 +315,18 @@ func (u *ExpenseController) UpdateRealization(c *fiber.Ctx) error {
req.Documents = form.File["documents"] req.Documents = form.File["documents"]
req.RealizationDate = c.FormValue("realization_date") realizationDate := c.FormValue("realization_date")
if realizationDate != "" {
req.RealizationDate = &realizationDate
}
realizationsJSON := c.FormValue("realizations") realizationsJSON := c.FormValue("realizations")
if realizationsJSON != "" { if realizationsJSON != "" {
if err := json.Unmarshal([]byte(realizationsJSON), &req.Realizations); err != nil { var realizations []validation.RealizationItem
if err := json.Unmarshal([]byte(realizationsJSON), &realizations); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid realizations JSON: %v", err)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid realizations JSON: %v", err))
} }
req.Realizations = &realizations
} }
expense, err := u.ExpenseService.UpdateRealization(c, uint(id), &req) expense, err := u.ExpenseService.UpdateRealization(c, uint(id), &req)
+30 -10
View File
@@ -1,7 +1,6 @@
package dto package dto
import ( import (
"encoding/json"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -41,8 +40,8 @@ type ExpenseListDTO struct {
type ExpenseDetailDTO struct { type ExpenseDetailDTO struct {
ExpenseBaseDTO ExpenseBaseDTO
Documents []DocumentDTO `json:"documents,omitempty"` Documents []DocumentDTO `json:"documents"`
RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` RealizationDocs []DocumentDTO `json:"realization_docs"`
Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` Kandangs []KandangGroupDTO `json:"kandangs,omitempty"`
TotalPengajuan float64 `json:"total_pengajuan"` TotalPengajuan float64 `json:"total_pengajuan"`
TotalRealisasi float64 `json:"total_realisasi"` TotalRealisasi float64 `json:"total_realisasi"`
@@ -77,7 +76,6 @@ type ExpenseRealizationDTO struct {
type KandangGroupDTO struct { type KandangGroupDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
KandangId uint64 `json:"kandang_id"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"` Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"`
Realisasi []ExpenseRealizationDTO `json:"realisasi,omitempty"` Realisasi []ExpenseRealizationDTO `json:"realisasi,omitempty"`
@@ -179,12 +177,18 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
var pengajuans []ExpenseNonstockDTO var pengajuans []ExpenseNonstockDTO
var realisasi []ExpenseRealizationDTO var realisasi []ExpenseRealizationDTO
if e.DocumentPath.Valid && e.DocumentPath.String != "" { for _, doc := range e.Documents {
json.Unmarshal([]byte(e.DocumentPath.String), &documents) documents = append(documents, DocumentDTO{
ID: uint64(doc.Id),
Path: doc.Path,
})
} }
if e.RealizationDocumentPath.Valid && e.RealizationDocumentPath.String != "" { for _, doc := range e.RealizationDocuments {
json.Unmarshal([]byte(e.RealizationDocumentPath.String), &realizationDocs) realizationDocs = append(realizationDocs, DocumentDTO{
ID: uint64(doc.Id),
Path: doc.Path,
})
} }
if len(e.Nonstocks) > 0 { if len(e.Nonstocks) > 0 {
@@ -264,6 +268,8 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO {
func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO { func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO {
kandangMap := make(map[uint64]*KandangGroupDTO) kandangMap := make(map[uint64]*KandangGroupDTO)
var directPengajuans []ExpenseNonstockDTO
var directRealisasi []ExpenseRealizationDTO
for _, p := range pengajuans { for _, p := range pengajuans {
var kandangId uint64 var kandangId uint64
@@ -280,16 +286,19 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
} }
if kandangId > 0 { if kandangId > 0 {
if kandangMap[kandangId] == nil { if kandangMap[kandangId] == nil {
kandangMap[kandangId] = &KandangGroupDTO{ kandangMap[kandangId] = &KandangGroupDTO{
Id: kandangId, Id: kandangId,
KandangId: kandangId,
Name: kandangName, Name: kandangName,
Pengajuans: []ExpenseNonstockDTO{}, Pengajuans: []ExpenseNonstockDTO{},
Realisasi: []ExpenseRealizationDTO{}, Realisasi: []ExpenseRealizationDTO{},
} }
} }
kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p) kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p)
} else {
directPengajuans = append(directPengajuans, p)
} }
} }
@@ -309,13 +318,24 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
if kandangMap[kandangId] == nil { if kandangMap[kandangId] == nil {
kandangMap[kandangId] = &KandangGroupDTO{ kandangMap[kandangId] = &KandangGroupDTO{
Id: kandangId, Id: kandangId,
KandangId: kandangId,
Name: kandangName, Name: kandangName,
Pengajuans: []ExpenseNonstockDTO{}, Pengajuans: []ExpenseNonstockDTO{},
Realisasi: []ExpenseRealizationDTO{}, Realisasi: []ExpenseRealizationDTO{},
} }
} }
kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r)
} else {
}
}
// If there are direct expenses (without kandang), add them as a special entry with id=0
if len(directPengajuans) > 0 || len(directRealisasi) > 0 {
kandangMap[0] = &KandangGroupDTO{
Id: 0,
Name: "",
Pengajuans: directPengajuans,
Realisasi: directRealisasi,
} }
} }
+7 -2
View File
@@ -1,6 +1,7 @@
package expenses package expenses
import ( import (
"context"
"fmt" "fmt"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -11,7 +12,6 @@ import (
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
sExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" sExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
@@ -32,15 +32,20 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
realizationRepo := rExpense.NewExpenseRealizationRepository(db) realizationRepo := rExpense.NewExpenseRealizationRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo) approvalSvc := commonSvc.NewApprovalService(approvalRepo)
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil {
panic(fmt.Sprintf("failed to create document service: %v", err))
}
// Register workflow steps for EXPENSES approval // Register workflow steps for EXPENSES approval
if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) panic(fmt.Sprintf("failed to register expense approval workflow: %v", err))
} }
expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, validate) expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ExpenseRoutes(router, userService, expenseService) ExpenseRoutes(router, userService, expenseService)
@@ -2,9 +2,11 @@ package repository
import ( import (
"context" "context"
"errors"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -13,6 +15,8 @@ type ExpenseRepository interface {
IdExists(ctx context.Context, id uint) (bool, error) IdExists(ctx context.Context, id uint) (bool, error)
GetNextSequence(ctx context.Context) (int, error) GetNextSequence(ctx context.Context) (int, error)
GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error) GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error)
WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB
CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error)
} }
type ExpenseRepositoryImpl struct { type ExpenseRepositoryImpl struct {
@@ -49,3 +53,57 @@ func (r *ExpenseRepositoryImpl) GetWithSupplier(ctx context.Context, id uint64)
} }
return &expense, nil return &expense, nil
} }
func (r *ExpenseRepositoryImpl) WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if pfkID == 0 && kandangID == 0 {
return db
}
q := db.Joins("JOIN expense_nonstocks ON expense_nonstocks.expense_id = expenses.id")
if pfkID > 0 && kandangID > 0 {
return q.Where("expense_nonstocks.project_flock_kandang_id = ? OR expense_nonstocks.kandang_id = ?", pfkID, kandangID)
}
if pfkID > 0 {
return q.Where("expense_nonstocks.project_flock_kandang_id = ?", pfkID)
}
return q.Where("expense_nonstocks.kandang_id = ?", kandangID)
}
}
func (r *ExpenseRepositoryImpl) CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) {
if pfkID == 0 && kandangID == 0 {
return 0, nil
}
var ids []uint64
if err := r.DB().WithContext(ctx).
Table("expenses").
Scopes(r.WithProjectFlockKandangFilter(pfkID, kandangID)).
Group("expenses.id").Where("expenses.deleted_at IS NULL").
Pluck("expenses.id", &ids).Error; err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, nil
}
var unfinished int64
for _, id := range ids {
var latest entity.Approval
err := r.DB().WithContext(ctx).
Table("approvals").
Where("approvable_type = ? AND approvable_id = ?", utils.ApprovalWorkflowExpense.String(), id).
Order("action_at DESC").
Limit(1).
First(&latest).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return 0, err
}
if isFinished != nil {
if !isFinished(&latest) {
unfinished++
}
}
}
return unfinished, nil
}
@@ -5,6 +5,8 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -12,6 +14,8 @@ type ExpenseRealizationRepository interface {
repository.BaseRepository[entity.ExpenseRealization] repository.BaseRepository[entity.ExpenseRealization]
IdExists(ctx context.Context, id uint64) (bool, error) IdExists(ctx context.Context, id uint64) (bool, error)
GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error)
GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error)
GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error)
} }
type ExpenseRealizationRepositoryImpl struct { type ExpenseRealizationRepositoryImpl struct {
@@ -30,11 +34,104 @@ func (r *ExpenseRealizationRepositoryImpl) IdExists(ctx context.Context, id uint
func (r *ExpenseRealizationRepositoryImpl) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) { func (r *ExpenseRealizationRepositoryImpl) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) {
var realization entity.ExpenseRealization var realization entity.ExpenseRealization
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).Where("expense_nonstock_id = ?", expenseNonstockID).First(&realization).Error
Where("expense_nonstock_id = ?", expenseNonstockID). return &realization, err
First(&realization).Error }
if err != nil {
return nil, err func (r *ExpenseRealizationRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) {
} var realizations []entity.ExpenseRealization
return &realization, nil err := r.DB().WithContext(ctx).
Preload("ExpenseNonstock").
Preload("ExpenseNonstock.Nonstock").
Preload("ExpenseNonstock.Nonstock.Uom").
Preload("ExpenseNonstock.Nonstock.Flags").
Preload("ExpenseNonstock.Expense").
Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id").
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id").
Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id").
Where("project_flock_kandangs.project_flock_id = ? OR kandangs.id IN (SELECT kandang_id FROM project_flock_kandangs WHERE project_flock_id = ?)", projectFlockID, projectFlockID).
Find(&realizations).Error
return realizations, err
}
func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) {
var realizations []entity.ExpenseRealization
var total int64
db := r.DB().WithContext(ctx).
Model(&entity.ExpenseRealization{}).
Preload("ExpenseNonstock", func(db *gorm.DB) *gorm.DB {
return db.
Preload("Expense").
Preload("Expense.Supplier").
Preload("Kandang").
Preload("Kandang.Location").
Preload("Nonstock").
Preload("Nonstock.Flags")
}).
Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id").
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
Joins("LEFT JOIN suppliers ON suppliers.id = expenses.supplier_id")
if filters.Search != "" {
db = db.Where("expenses.category LIKE ? OR expenses.reference_number LIKE ? OR expenses.po_number LIKE ? OR expenses.notes LIKE ? OR suppliers.name LIKE ?",
"%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%")
}
if filters.Category != "" {
db = db.Where("expenses.category = ?", filters.Category)
}
if filters.SupplierId > 0 {
db = db.Where("expenses.supplier_id = ?", filters.SupplierId)
}
if filters.KandangId > 0 {
db = db.Where("expense_nonstocks.kandang_id = ?", filters.KandangId)
}
if filters.ProjectFlockKandangId > 0 {
db = db.Where("expense_nonstocks.project_flock_kandang_id = ?", filters.ProjectFlockKandangId)
}
if filters.NonstockId > 0 {
db = db.Where("expense_nonstocks.nonstock_id = ?", filters.NonstockId)
}
locationID := filters.LocationId
areaID := filters.AreaId
if locationID > 0 || areaID > 0 {
db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id")
if locationID > 0 {
db = db.Where("kandangs.location_id = ?", uint(locationID))
}
if areaID > 0 {
db = db.Joins("JOIN locations ON locations.id = kandangs.location_id").
Where("locations.area_id = ?", uint(areaID))
}
}
if filters.RealizationDate != "" {
if realizationDate, err := utils.ParseDateString(filters.RealizationDate); err == nil {
db = db.Where("DATE(expenses.realization_date) = ?", realizationDate)
}
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.
Offset(offset).
Limit(limit).
Order("expense_realizations.created_at DESC").
Find(&realizations).Error; err != nil {
return nil, 0, err
}
return realizations, total, nil
} }
+14 -12
View File
@@ -14,22 +14,24 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService
route := v1.Group("/expenses") route := v1.Group("/expenses")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
// route.Use(m.Auth(u))
// route.Get("/", m.Auth(u), ctrl.GetAll) // route.Get("/", m.Auth(u), ctrl.GetAll)
// route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Post("/", m.Auth(u), ctrl.CreateOne)
// route.Get("/:id", m.Auth(u), ctrl.GetOne) // route.Get("/:id", m.Auth(u), ctrl.GetOne)
// route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne)
// route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne)
route.Get("/", ctrl.GetAll) route.Get("/",m.RequirePermissions(m.P_ExpenseGetAll), ctrl.GetAll)
route.Post("/", ctrl.CreateOne) route.Post("/",m.RequirePermissions(m.P_ExpenseCreateOne), ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne) route.Get("/:id",m.RequirePermissions(m.P_ExpenseGetOne), ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne) route.Patch("/:id",m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id",m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne)
route.Post("/approvals/manager", ctrl.Approval) route.Post("/approvals/manager",m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval)
route.Post("/approvals/finance", ctrl.Approval) route.Post("/approvals/finance",m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval)
route.Post("/:id/realizations", ctrl.CreateRealization) route.Post("/:id/realizations",m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization)
route.Patch("/:id/realizations", ctrl.UpdateRealization) route.Patch("/:id/realizations",m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization)
route.Post("/:id/complete", ctrl.CompleteExpense) route.Post("/:id/complete",m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense)
route.Delete("/:id/documents/:documentId", ctrl.DeleteDocument) route.Delete("/:id/documents/:documentId",m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument)
route.Delete("/:id/realization-documents/:documentId", ctrl.DeleteRealizationDocument) route.Delete("/:id/realization-documents/:documentId",m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument)
} }
@@ -2,15 +2,14 @@ package service
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"mime/multipart"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware"
expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
@@ -48,9 +47,10 @@ type expenseService struct {
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
RealizationRepository repository.ExpenseRealizationRepository RealizationRepository repository.ExpenseRealizationRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
DocumentSvc commonSvc.DocumentService
} }
func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService { func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService {
return &expenseService{ return &expenseService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
@@ -60,6 +60,7 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
RealizationRepository: realizationRepo, RealizationRepository: realizationRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc,
} }
} }
@@ -71,7 +72,13 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Nonstocks.Realization"). Preload("Nonstocks.Realization").
Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location").
Preload("Nonstocks.Kandang"). Preload("Nonstocks.Kandang").
Preload("Nonstocks.Kandang.Location") Preload("Nonstocks.Kandang.Location").
Preload("Documents", func(db *gorm.DB) *gorm.DB {
return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense))
}).
Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB {
return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization))
})
} }
func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) {
@@ -138,11 +145,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
supplierID := uint(req.SupplierID) supplierID := uint(req.SupplierID)
supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) {
return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id)
}
if err := commonSvc.EnsureRelations(c.Context(), if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc}, commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists},
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -183,17 +187,57 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction) projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction)
referenceNumber, err := s.generateReferenceNumber(dbTransaction) referenceNumber, err := GenerateExpenseReferenceNumber(c.Context(), expenseRepoTx)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number") return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number")
} }
createdBy := uint64(1) //todo get from auth actorID, err := middleware.ActorIDFromContext(c)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
createdBy := uint64(actorID)
hasKandang := false
for _, ens := range req.ExpenseNonstocks {
if ens.KandangID != nil {
hasKandang = true
break
}
}
var projectFlockIdJSON *string
if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) {
projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction)
activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location")
}
if len(activeProjectFlocks) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "No active project flocks found for this location")
}
projectFlockIDs := make([]uint64, len(activeProjectFlocks))
for i, pf := range activeProjectFlocks {
projectFlockIDs[i] = uint64(pf.Id)
}
projectFlockIdsJSON, err := json.Marshal(projectFlockIDs)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids")
}
jsonStr := string(projectFlockIdsJSON)
projectFlockIdJSON = &jsonStr
}
expense = &entity.Expense{ expense = &entity.Expense{
ReferenceNumber: referenceNumber, ReferenceNumber: referenceNumber,
PoNumber: req.PoNumber, PoNumber: req.PoNumber,
Category: req.Category, Category: req.Category,
SupplierId: req.SupplierID, SupplierId: req.SupplierID,
LocationId: req.LocationID,
ProjectFlockId: projectFlockIdJSON,
TransactionDate: expenseDate, TransactionDate: expenseDate,
CreatedBy: createdBy, CreatedBy: createdBy,
} }
@@ -206,35 +250,36 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
for _, expenseNonstock := range req.ExpenseNonstocks { for _, expenseNonstock := range req.ExpenseNonstocks {
isAttachingToKandang := (expenseNonstock.KandangID != nil)
var projectFlockKandangId *uint64 var projectFlockKandangId *uint64
var kandangId *uint64
if req.Category == "BOP" { if isAttachingToKandang {
kandangId = expenseNonstock.KandangID
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) if req.Category == string(utils.ExpenseCategoryBOP) {
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId))
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
} }
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
} }
id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id } else {
kandangId = nil
projectFlockKandangId = nil
} }
for _, costItem := range expenseNonstock.CostItems { for _, costItem := range expenseNonstock.CostItems {
nonstockId := costItem.NonstockID nonstockId := costItem.NonstockID
var kandangId *uint64 newExpenseNonstock := &entity.ExpenseNonstock{
if req.Category == "NON-BOP" {
id := uint64(expenseNonstock.KandangID)
kandangId = &id
} else if req.Category == "BOP" {
if projectFlockKandangId != nil {
kandangId = &expenseNonstock.KandangID
}
}
expenseNonstock := &entity.ExpenseNonstock{
ExpenseId: &expense.Id, ExpenseId: &expense.Id,
ProjectFlockKandangId: projectFlockKandangId, ProjectFlockKandangId: projectFlockKandangId,
KandangId: kandangId, KandangId: kandangId,
@@ -244,7 +289,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
Notes: costItem.Notes, Notes: costItem.Notes,
} }
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
} }
} }
@@ -264,9 +309,23 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval")
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err for idx, file := range req.Documents {
documentFiles = append(documentFiles, commonSvc.DocumentFile{
File: file,
Type: string(utils.DocumentTypeExpense),
Index: &idx,
})
}
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeExpense),
DocumentableID: expense.Id,
CreatedBy: &createdByUint,
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents")
} }
} }
@@ -337,6 +396,11 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
updateBody["supplier_id"] = *req.SupplierID updateBody["supplier_id"] = *req.SupplierID
} }
if req.LocationID != nil {
locationID := uint(*req.LocationID)
updateBody["location_id"] = locationID
}
if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 { if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 {
responseDTO, err := s.GetOne(c, id) responseDTO, err := s.GetOne(c, id)
@@ -360,6 +424,9 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
} }
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil {
return err
}
categoryChanged := false categoryChanged := false
var newCategory string var newCategory string
if req.Category != nil && *req.Category != currentExpense.Category { if req.Category != nil && *req.Category != currentExpense.Category {
@@ -377,7 +444,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
} }
if categoryChanged { if categoryChanged {
if currentExpense.Category == "BOP" && newCategory == "NON-BOP" { if currentExpense.Category == string(utils.ExpenseCategoryBOP) && newCategory == string(utils.ExpenseCategoryNonBOP) {
var existingExpenseNonstocks []entity.ExpenseNonstock var existingExpenseNonstocks []entity.ExpenseNonstock
if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil {
@@ -392,7 +459,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id to null") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id to null")
} }
} }
} else if currentExpense.Category == "NON-BOP" && newCategory == "BOP" { } else if currentExpense.Category == string(utils.ExpenseCategoryNonBOP) && newCategory == string(utils.ExpenseCategoryBOP) {
var existingExpenseNonstocks []entity.ExpenseNonstock var existingExpenseNonstocks []entity.ExpenseNonstock
if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil {
@@ -404,6 +471,9 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if ens.KandangId != nil { if ens.KandangId != nil {
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*ens.KandangId)) projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*ens.KandangId))
if err != nil { if err != nil {
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
} }
@@ -445,18 +515,26 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
for _, expenseNonstock := range *req.ExpenseNonstocks { for _, expenseNonstock := range *req.ExpenseNonstocks {
var projectFlockKandangId *uint64 var projectFlockKandangId *uint64
var kandangId *uint64
if updatedExpense.Category == "BOP" { // Check if attaching to kandang
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) if expenseNonstock.KandangID != nil {
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) kandangId = expenseNonstock.KandangID
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") // BOP with kandang: Get active project flock kandang
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
} }
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
} }
id := uint64(projectFlockKandang.Id) // NON-BOP: projectFlockKandangId stays nil
projectFlockKandangId = &id
} }
for _, costItem := range expenseNonstock.CostItems { for _, costItem := range expenseNonstock.CostItems {
@@ -468,18 +546,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return err return err
} }
var kandangId *uint64
if updatedExpense.Category == "NON-BOP" {
id := uint64(expenseNonstock.KandangID)
kandangId = &id
} else if updatedExpense.Category == "BOP" {
if projectFlockKandangId != nil {
kandangId = &expenseNonstock.KandangID
}
}
expenseId := uint64(id) expenseId := uint64(id)
expenseNonstock := &entity.ExpenseNonstock{ newExpenseNonstock := &entity.ExpenseNonstock{
ExpenseId: &expenseId, ExpenseId: &expenseId,
ProjectFlockKandangId: projectFlockKandangId, ProjectFlockKandangId: projectFlockKandangId,
KandangId: kandangId, KandangId: kandangId,
@@ -489,14 +557,17 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
Notes: costItem.Notes, Notes: costItem.Notes,
} }
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
} }
} }
} }
} }
actorID := uint(1) // TODO: replace with authenticated user id actorID, err := middleware.ActorIDFromContext(c)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
if *latestApproval.Action != entity.ApprovalActionUpdated { if *latestApproval.Action != entity.ApprovalActionUpdated {
approvalAction := entity.ApprovalActionUpdated approvalAction := entity.ApprovalActionUpdated
@@ -513,9 +584,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
} }
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err for idx, file := range req.Documents {
documentFiles = append(documentFiles, commonSvc.DocumentFile{
File: file,
Type: string(utils.DocumentTypeExpense),
Index: &idx,
})
}
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeExpense),
DocumentableID: uint64(id),
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents")
} }
} }
@@ -543,7 +628,21 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error {
); err != nil { ); err != nil {
return err return err
} }
expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Preload("Nonstocks")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
}
s.Log.Errorf("Failed to get expense for ID %d: %+v", id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
}
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Expense not found for ID %d: %+v", id, err) s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
@@ -572,6 +671,20 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format")
} }
expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB {
return db.Preload("Nonstocks")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
}
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
return nil, err
}
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
@@ -616,9 +729,24 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err for idx, file := range req.Documents {
documentFiles = append(documentFiles, commonSvc.DocumentFile{
File: file,
Type: string(utils.DocumentTypeExpenseRealization),
Index: &idx,
})
}
actorID := uint(1) // TODO: replace with authenticated user id
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeExpenseRealization),
DocumentableID: uint64(expenseID),
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents")
} }
} }
@@ -655,7 +783,10 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (
return nil, err return nil, err
} }
actorID := uint(1) // TODO: replace with authenticated user id actorID, err := middleware.ActorIDFromContext(c)
if err != nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -712,7 +843,19 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
); err != nil { ); err != nil {
return nil, err return nil, err
} }
expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB {
return db.Preload("Nonstocks")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
}
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
return nil, err
}
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil) latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
@@ -732,10 +875,10 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
expenseRepoTx := repository.NewExpenseRepository(tx) expenseRepoTx := repository.NewExpenseRepository(tx)
// Check if only updating documents // Check if only updating documents
updateDataOnly := len(req.Realizations) == 0 && len(req.Documents) > 0 updateDataOnly := req.Realizations == nil && len(req.Documents) > 0
if len(req.Realizations) > 0 { if req.Realizations != nil {
for _, realizationItem := range req.Realizations { for _, realizationItem := range *req.Realizations {
expenseNonstockID := realizationItem.ExpenseNonstockID expenseNonstockID := realizationItem.ExpenseNonstockID
@@ -770,9 +913,30 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
} }
if len(req.Documents) > 0 { if req.RealizationDate != nil {
if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{"realization_date": *req.RealizationDate}, nil); err != nil {
return err return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
}
}
if s.DocumentSvc != nil && len(req.Documents) > 0 {
documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
for idx, file := range req.Documents {
documentFiles = append(documentFiles, commonSvc.DocumentFile{
File: file,
Type: string(utils.DocumentTypeExpenseRealization),
Index: &idx,
})
}
actorID := uint(1) // TODO: replace with authenticated user id
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeExpenseRealization),
DocumentableID: uint64(expenseID),
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents")
} }
} }
@@ -807,79 +971,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
return responseDTO, nil return responseDTO, nil
} }
func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx repository.ExpenseRepository, expenseID uint, documents []*multipart.FileHeader, isRealization bool) error {
if len(documents) == 0 {
return nil
}
var existingDocuments []expenseDto.DocumentDTO
var fieldName string
if isRealization {
fieldName = "realization_document_path"
} else {
fieldName = "document_path"
}
expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document processing")
}
} else {
var documentField sql.NullString
if isRealization {
documentField = expense.RealizationDocumentPath
} else {
documentField = expense.DocumentPath
}
if documentField.Valid && documentField.String != "" {
if err := json.Unmarshal([]byte(documentField.String), &existingDocuments); err != nil {
existingDocuments = []expenseDto.DocumentDTO{}
}
}
}
var startID uint64 = 1
if len(existingDocuments) > 0 {
maxID := uint64(0)
for _, doc := range existingDocuments {
if doc.ID > maxID {
maxID = doc.ID
}
}
startID = maxID + 1
}
for i, doc := range documents {
documentPath := doc.Filename
document := expenseDto.DocumentDTO{
ID: startID + uint64(i),
Path: documentPath,
}
existingDocuments = append(existingDocuments, document)
}
documentJSON, err := json.Marshal(existingDocuments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{
fieldName: string(documentJSON),
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
return nil
}
func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error { func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error {
if err := commonSvc.EnsureRelations(ctx.Context(), if err := commonSvc.EnsureRelations(ctx.Context(),
@@ -888,62 +979,40 @@ func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, document
return err return err
} }
if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { if s.DocumentSvc == nil {
expenseRepoTx := repository.NewExpenseRepository(tx) return fiber.NewError(fiber.StatusInternalServerError, "Document service not available")
}
expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) // Verify document exists and belongs to the expense
if err != nil { var documentableType string
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document deletion") if isRealization {
documentableType = string(utils.DocumentableTypeExpenseRealization)
} else {
documentableType = string(utils.DocumentableTypeExpense)
}
documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents")
}
documentFound := false
var documentIDsToDelete []uint
for _, doc := range documents {
if uint64(doc.Id) == documentID {
documentFound = true
documentIDsToDelete = append(documentIDsToDelete, doc.Id)
break
} }
}
var existingDocuments []expenseDto.DocumentDTO if !documentFound {
var fieldName string return fiber.NewError(fiber.StatusNotFound, "Document not found")
}
if isRealization { // Delete document from database and storage
fieldName = "realization_document_path" if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil {
if expense.RealizationDocumentPath.Valid && expense.RealizationDocumentPath.String != "" { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document")
if err := json.Unmarshal([]byte(expense.RealizationDocumentPath.String), &existingDocuments); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing realization documents")
}
}
} else {
fieldName = "document_path"
if expense.DocumentPath.Valid && expense.DocumentPath.String != "" {
if err := json.Unmarshal([]byte(expense.DocumentPath.String), &existingDocuments); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing documents")
}
}
}
var updatedDocuments []expenseDto.DocumentDTO
documentFound := false
for _, doc := range existingDocuments {
if doc.ID == documentID {
documentFound = true
continue
}
updatedDocuments = append(updatedDocuments, doc)
}
if !documentFound {
return fiber.NewError(fiber.StatusNotFound, "Document not found")
}
documentJSON, err := json.Marshal(updatedDocuments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{
fieldName: string(documentJSON),
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
return nil
}); err != nil {
return err
} }
return nil return nil
@@ -954,11 +1023,14 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
return nil, fiber.NewError(fiber.StatusBadRequest, "No expense IDs provided") return nil, fiber.NewError(fiber.StatusBadRequest, "No expense IDs provided")
} }
actorID := uint(1) // TODO: replace with authenticated user id actorID, err := middleware.ActorIDFromContext(c)
if err != nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
var results []expenseDto.ExpenseDetailDTO var results []expenseDto.ExpenseDetailDTO
err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
expenseRepoTx := repository.NewExpenseRepository(tx) expenseRepoTx := repository.NewExpenseRepository(tx)
@@ -1004,6 +1076,21 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
} else { } else {
return fiber.NewError(fiber.StatusBadRequest, "Invalid approval action") return fiber.NewError(fiber.StatusBadRequest, "Invalid approval action")
} }
if approvalAction == entity.ApprovalActionApproved {
expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Preload("Nonstocks")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
}
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
return err
}
}
if _, err := approvalSvcTx.CreateApproval( if _, err := approvalSvcTx.CreateApproval(
c.Context(), c.Context(),
@@ -1050,17 +1137,6 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
return results, nil return results, nil
} }
func (s *expenseService) generateReferenceNumber(ctx *gorm.DB) (string, error) {
sequence, err := s.Repository.GetNextSequence(ctx.Statement.Context)
if err != nil {
return "", err
}
refNum := fmt.Sprintf("BOP-LTI-%05d", sequence)
return refNum, nil
}
func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) { func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) {
expenseRepoTx := repository.NewExpenseRepository(ctx) expenseRepoTx := repository.NewExpenseRepository(ctx)
@@ -1084,13 +1160,45 @@ func (s *expenseService) validateExpenseNonstockRelation(ctx *fiber.Ctx, expense
return nil return nil
} }
// func actorIDFromContext(c *fiber.Ctx) (uint, error) { func (s *expenseService) ensureProjectFlockNotClosedForExpense(
// user, ok := authmiddleware.AuthenticatedUser(c) ctx context.Context,
// if !ok || user == nil || user.Id == 0 { expense *entity.Expense,
// return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") ) error {
// } // Kalau repo belum di-wire atau expense kosong → gak usah ngecek apa-apa
// return user.Id, nil if s.ProjectFlockKandangRepo == nil || expense == nil {
// } return nil
}
// return user.Id, nil seen := make(map[uint]struct{})
// }
for _, ens := range expense.Nonstocks {
// Field ini pointer, bisa nil
if ens.ProjectFlockKandangId == nil || *ens.ProjectFlockKandangId == 0 {
continue
}
pfkID := uint(*ens.ProjectFlockKandangId)
if _, ok := seen[pfkID]; ok {
continue
}
seen[pfkID] = struct{}{}
pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, pfkID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Project flock %d tidak ditemukan", pfkID),
)
}
s.Log.Errorf("Failed to validate project flock %d for expense %d: %+v", pfkID, expense.Id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
}
// ❗ RULE: kalau ClosedAt tidak nil → project sudah closing
if pfk.ClosedAt != nil {
return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
}
}
return nil
}
@@ -0,0 +1,17 @@
package service
import (
"context"
"fmt"
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
)
// GenerateExpenseReferenceNumber builds a new reference number using the expense sequence.
func GenerateExpenseReferenceNumber(ctx context.Context, repo expenseRepo.ExpenseRepository) (string, error) {
sequence, err := repo.GetNextSequence(ctx)
if err != nil {
return "", err
}
return fmt.Sprintf("BOP-LTI-%05d", sequence), nil
}
@@ -5,16 +5,17 @@ import (
) )
type Create struct { type Create struct {
PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"` PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"`
TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"`
Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"`
SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` LocationID uint64 `form:"location_id" json:"location_id" validate:"required,gt=0"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"` ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"`
} }
type ExpenseNonstock struct { type ExpenseNonstock struct {
KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"` KandangID *uint64 `form:"kandang_id" json:"kandang_id" validate:"omitempty"`
CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"` CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"`
} }
@@ -22,15 +23,16 @@ type CostItem struct {
NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"` NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"`
Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"` Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"`
Price float64 `form:"price" json:"price" validate:"required,gt=0"` Price float64 `form:"price" json:"price" validate:"required,gt=0"`
Notes string `form:"notes" json:"notes" validate:"required,max=500"` Notes string `form:"notes" json:"notes" validate:"omitempty,max=500"`
} }
type Update struct { type Update struct {
TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"`
Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"`
SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"`
LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
} }
type Query struct { type Query struct {
@@ -46,9 +48,9 @@ type CreateRealization struct {
} }
type UpdateRealization struct { type UpdateRealization struct {
RealizationDate string `form:"realization_date" json:"realization_date" validate:"omitempty,datetime=2006-01-02"` RealizationDate *string `form:"realization_date" json:"realization_date" validate:"omitempty,datetime=2006-01-02"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
Realizations []RealizationItem `form:"realizations" json:"realizations" validate:"required,min=1,dive"` Realizations *[]RealizationItem `form:"realizations" json:"realizations" validate:"omitempty,min=1,dive"`
} }
type RealizationItem struct { type RealizationItem struct {
@@ -0,0 +1,92 @@
package controller
import (
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type InitialController struct {
InitialService service.InitialService
}
func NewInitialController(initialService service.InitialService) *InitialController {
return &InitialController{
InitialService: initialService,
}
}
func (u *InitialController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.InitialService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get initial successfully",
Data: dto.ToInitialListDTO(*result),
})
}
func (u *InitialController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.InitialService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create initial successfully",
Data: dto.ToInitialListDTO(*result),
})
}
func (u *InitialController) UpdateOne(c *fiber.Ctx) error {
req := new(validation.Update)
param := c.Params("id")
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.InitialService.UpdateOne(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update initial successfully",
Data: dto.ToInitialListDTO(*result),
})
}
@@ -0,0 +1,163 @@
package dto
import (
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === DTO Structs ===
type InitialRelationDTO struct {
Id uint `json:"id"`
ReferenceNumber string `json:"reference_number"`
TransactionType string `json:"transaction_type"`
InitialBalanceType string `json:"initial_balance_type"`
InitialBalanceTypeLabel string `json:"initial_balance_type_label"`
Party Party `json:"party"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
Direction string `json:"direction"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
}
type InitialListDTO struct {
InitialRelationDTO
CreatedBy uint `json:"created_by"`
CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type InitialDetailDTO struct {
InitialListDTO
}
type Party struct {
Id uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
AccountNumber string `json:"account_number"`
}
// === Mapper Functions ===
func ToInitialRelationDTO(e entity.Payment) InitialRelationDTO {
reference := ""
if e.ReferenceNumber != nil {
reference = *e.ReferenceNumber
}
initialBalanceType := initialBalanceTypeFromPayment(e)
return InitialRelationDTO{
Id: e.Id,
ReferenceNumber: reference,
TransactionType: transactionTypeLabel(e.TransactionType),
InitialBalanceType: initialBalanceType,
InitialBalanceTypeLabel: initialBalanceLabel(initialBalanceType),
Party: partyFromInitial(e),
Bank: bankFromInitial(e),
Direction: e.Direction,
Nominal: e.Nominal,
Notes: e.Notes,
}
}
func ToInitialListDTO(e entity.Payment) InitialListDTO {
approval := approvalDTO.ApprovalRelationDTO{}
if e.LatestApproval != nil {
approval = approvalDTO.ToApprovalDTO(*e.LatestApproval)
}
return InitialListDTO{
InitialRelationDTO: ToInitialRelationDTO(e),
CreatedBy: e.CreatedBy,
CreatedByUser: userFromInitial(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Approval: approval,
}
}
func ToInitialListDTOs(e []entity.Payment) []InitialListDTO {
result := make([]InitialListDTO, len(e))
for i, r := range e {
result[i] = ToInitialListDTO(r)
}
return result
}
func ToInitialDetailDTO(e entity.Payment) InitialDetailDTO {
return InitialDetailDTO{
InitialListDTO: ToInitialListDTO(e),
}
}
func partyFromInitial(e entity.Payment) Party {
party := Party{
Id: e.PartyId,
Type: e.PartyType,
}
switch utils.PaymentParty(e.PartyType) {
case utils.PaymentPartyCustomer:
if e.Customer != nil && e.Customer.Id != 0 {
party.Name = e.Customer.Name
party.AccountNumber = e.Customer.AccountNumber
}
case utils.PaymentPartySupplier:
if e.Supplier != nil && e.Supplier.Id != 0 {
party.Name = e.Supplier.Name
if e.Supplier.AccountNumber != nil {
party.AccountNumber = *e.Supplier.AccountNumber
}
}
}
return party
}
func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{}
}
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromInitial(e entity.Payment) userDTO.UserRelationDTO {
if e.CreatedUser.Id == 0 {
return userDTO.UserRelationDTO{}
}
return userDTO.ToUserRelationDTO(e.CreatedUser)
}
func transactionTypeLabel(transactionType string) string {
if strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) {
return "Saldo Awal"
}
return transactionType
}
func initialBalanceLabel(balanceType string) string {
switch strings.ToUpper(strings.TrimSpace(balanceType)) {
case "NEGATIVE":
return "Saldo Awal Negatif"
case "POSITIVE":
return "Saldo Awal Positif"
default:
return balanceType
}
}
func initialBalanceTypeFromPayment(e entity.Payment) string {
if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 {
return "NEGATIVE"
}
return "POSITIVE"
}
@@ -0,0 +1,36 @@
package initials
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
rInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories"
sInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type InitialModule struct{}
func (InitialModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
initialRepo := rInitial.NewInitialRepository(db)
userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register initial approval workflow: %v", err))
}
initialService := sInitial.NewInitialService(initialRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
InitialRoutes(router, userService, initialService)
}
@@ -0,0 +1,51 @@
package repository
import (
"context"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type InitialRepository interface {
repository.BaseRepository[entity.Payment]
BankExists(ctx context.Context, bankId uint) (bool, error)
CustomerExists(ctx context.Context, customerId uint) (bool, error)
SupplierExists(ctx context.Context, supplierId uint) (bool, error)
NextPaymentSequence(ctx context.Context) (int64, error)
}
type InitialRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Payment]
db *gorm.DB
}
func NewInitialRepository(db *gorm.DB) InitialRepository {
return &InitialRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db),
db: db,
}
}
func (r *InitialRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) {
return repository.Exists[entity.Bank](ctx, r.db, bankId)
}
func (r *InitialRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) {
return repository.Exists[entity.Customer](ctx, r.db, customerId)
}
func (r *InitialRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) {
return repository.Exists[entity.Supplier](ctx, r.db, supplierId)
}
func (r *InitialRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) {
var next int64
if err := r.db.WithContext(ctx).
Raw("SELECT nextval('payments_code_seq')").
Scan(&next).Error; err != nil {
return 0, err
}
return next, nil
}
@@ -0,0 +1,21 @@
package initials
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/controllers"
initial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func InitialRoutes(v1 fiber.Router, u user.UserService, s initial.InitialService) {
ctrl := controller.NewInitialController(s)
route := v1.Group("/initial-balances")
route.Use(m.Auth(u))
route.Post("/",m.RequirePermissions(m.P_Finances_Initial_Balances_CreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_Finances_Initial_Balances_GetOne), ctrl.GetOne)
route.Patch("/:id",m.RequirePermissions(m.P_Finances_Initial_Balances_UpdateOne), ctrl.UpdateOne)
}
@@ -0,0 +1,336 @@
package service
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type InitialService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error)
}
type initialService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.InitialRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
func NewInitialService(
repo repository.InitialRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
) InitialService {
return &initialService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowInitial,
}
}
func (s initialService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser").
Preload("BankWarehouse").
Preload("Customer").
Preload("Supplier")
}
func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
initial, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
if err != nil {
s.Log.Errorf("Failed get initial by id: %+v", err)
return nil, err
}
if !isInitialTransaction(initial.TransactionType) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
if s.ApprovalSvc != nil {
approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier())
if err != nil {
s.Log.Warnf("Unable to load latest approval for initial %d: %+v", id, err)
} else {
initial.LatestApproval = approval
}
}
return initial, nil
}
func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
party, err := normalizePartyType(req.PartyType)
if err != nil {
return nil, err
}
if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil {
return nil, err
}
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
balanceType, err := normalizeInitialBalanceType(req.InitialBalanceType)
if err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
code, err := s.generateInitialCode(c.Context())
if err != nil {
return nil, err
}
reference := req.ReferenceNumber
createBody := &entity.Payment{
PaymentCode: code,
ReferenceNumber: &reference,
TransactionType: string(utils.TransactionTypeSaldoAwal),
PartyType: party,
PartyId: req.PartyId,
PaymentDate: time.Now(),
PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId,
Direction: directionForInitialType(balanceType),
Nominal: signedNominal(balanceType, req.Nominal),
Notes: req.Note,
CreatedBy: actorID,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
initialRepoTx := repository.NewInitialRepository(dbTransaction)
if err := initialRepoTx.CreateOne(c.Context(), createBody, nil); err != nil {
return err
}
if s.ApprovalSvc != nil {
action := entity.ApprovalActionCreated
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
_, err := approvalSvcTx.CreateApproval(
c.Context(),
s.approvalWorkflow,
createBody.Id,
utils.InitialStepPengajuan,
&action,
actorID,
nil,
)
if err != nil {
return err
}
}
return nil
})
if err != nil {
s.Log.Errorf("Failed to create initial: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
if req.ReferenceNumber != nil {
updateBody["reference_number"] = *req.ReferenceNumber
}
if req.Note != nil {
updateBody["notes"] = *req.Note
}
if req.BankId != nil {
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
updateBody["bank_id"] = *req.BankId
}
requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil
requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil
var existing *entity.Payment
if requiresVerification {
current, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
if err != nil {
s.Log.Errorf("Failed get initial by id: %+v", err)
return nil, err
}
if !isInitialTransaction(current.TransactionType) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
existing = current
}
if req.PartyType != nil || req.PartyId != nil {
partyType := existing.PartyType
partyId := existing.PartyId
if req.PartyType != nil {
normalized, err := normalizePartyType(*req.PartyType)
if err != nil {
return nil, err
}
partyType = normalized
updateBody["party_type"] = partyType
}
if req.PartyId != nil {
partyId = *req.PartyId
updateBody["party_id"] = partyId
}
if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil {
return nil, err
}
}
if req.InitialBalanceType != nil || req.Nominal != nil {
balanceType := balanceTypeFromPayment(existing)
if req.InitialBalanceType != nil {
normalized, err := normalizeInitialBalanceType(*req.InitialBalanceType)
if err != nil {
return nil, err
}
balanceType = normalized
}
nominal := math.Abs(existing.Nominal)
if req.Nominal != nil {
nominal = *req.Nominal
}
updateBody["direction"] = directionForInitialType(balanceType)
updateBody["nominal"] = signedNominal(balanceType, nominal)
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
s.Log.Errorf("Failed to update initial: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func isInitialTransaction(transactionType string) bool {
return strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal))
}
func balanceTypeFromPayment(payment *entity.Payment) string {
if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 {
return "NEGATIVE"
}
return "POSITIVE"
}
func normalizePartyType(partyType string) (string, error) {
party := strings.ToUpper(strings.TrimSpace(partyType))
if !utils.IsValidPaymentParty(party) {
return "", utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
return party, nil
}
func normalizeInitialBalanceType(balanceType string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(balanceType))
switch normalized {
case "NEGATIVE", "POSITIVE":
return normalized, nil
default:
return "", utils.BadRequest("`initial_balance_type` must be `NEGATIVE` or `POSITIVE`")
}
}
func directionForInitialType(balanceType string) string {
if strings.EqualFold(balanceType, "NEGATIVE") {
return "OUT"
}
return "IN"
}
func signedNominal(balanceType string, nominal float64) float64 {
normalized := math.Abs(nominal)
if strings.EqualFold(balanceType, "NEGATIVE") {
return -normalized
}
return normalized
}
func (s initialService) generateInitialCode(ctx context.Context) (string, error) {
sequence, err := s.Repository.NextPaymentSequence(ctx)
if err != nil {
return "", err
}
return fmt.Sprintf("INIT-%05d", sequence), nil
}
func (s initialService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
}
}
func (s initialService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error {
switch utils.PaymentParty(partyType) {
case utils.PaymentPartyCustomer:
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists},
)
case utils.PaymentPartySupplier:
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists},
)
default:
return utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
}
func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) error {
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists},
)
}
@@ -0,0 +1,27 @@
package validation
type Create struct {
PartyType string `json:"party_type" validate:"required_strict,max=50"`
PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"`
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"`
InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"`
Note string `json:"note" validate:"required_strict,max=500"`
}
type Update struct {
PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"`
PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"`
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
ReferenceNumber *string `json:"reference_number,omitempty" validate:"omitempty,max=100"`
InitialBalanceType *string `json:"initial_balance_type,omitempty" validate:"omitempty,oneof=NEGATIVE POSITIVE"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
Note *string `json:"note,omitempty" validate:"omitempty,max=500"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
}

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