Compare commits

..

187 Commits

Author SHA1 Message Date
Sasanka 049c98298a Ui fixings 2026-09-24 12:30:21 +05:30
Sasanka f5e8e0e5b9 responsiveness and ui fixers 2026-09-23 16:55:46 +05:30
ashan_rusiru 4c5e9b347a Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m41s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m5s
2026-09-21 19:03:08 +00:00
Dhananjaya99 fdd104284e feat: add ItemOverviewDto for enriched item data in items list
feat: implement CustomerDetailPage for detailed customer view and editing

feat: create CustomersPage for listing and managing customers with filters

feat: add ColumnsMenu component for customizable table columns visibility

feat: implement CSV export functionality for data download

feat: add customer code generation logic for unique customer identifiers
2026-09-22 00:32:40 +05:30
ImanThiyanga 72819e5f7b production change 2026-09-21 18:02:55 +05:30
kalana_Thilakarathna edd1011330 Merge pull request 'chore: update next.js version to ^16.3.5 in package.json' (#85) from Dev into production
Reviewed-on: #85
2026-09-20 12:41:34 +00:00
Kalana B. Thilakarathna 3ae14fad72 chore: update next.js version to ^16.3.5 in package.json
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m45s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m23s
2026-09-20 18:11:04 +05:30
kalana_Thilakarathna 84d3318f76 Merge pull request 'Dev' (#84) from Dev into production
Reviewed-on: #84
2026-09-20 12:27:45 +00:00
kalana_Thilakarathna bfb8e83a82 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 10m33s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m9s
2026-09-20 12:27:31 +00:00
kalana_Thilakarathna 40c1d6da27 Update Frontend/erp-system/Dockerfile 2026-09-20 12:26:57 +00:00
kalana_Thilakarathna 5aa709db17 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#83) from Dev into production
Reviewed-on: #83
2026-09-20 12:03:21 +00:00
kalana_Thilakarathna cf964a19a9 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m37s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m59s
2026-09-20 12:03:07 +00:00
kalana_Thilakarathna a40c6bf598 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#82) from Dev into production
Reviewed-on: #82
2026-09-20 11:57:04 +00:00
kalana_Thilakarathna f951700b58 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m16s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m55s
2026-09-20 11:56:50 +00:00
kalana_Thilakarathna b3939e0685 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#81) from Dev into production
Reviewed-on: #81
2026-09-20 11:50:14 +00:00
kalana_Thilakarathna af6f02023d Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 11m43s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m5s
2026-09-20 11:49:55 +00:00
kalana_Thilakarathna 9d569a1504 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#80) from Dev into production
Reviewed-on: #80
2026-09-20 11:41:23 +00:00
kalana_Thilakarathna 5a44702cf3 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 10m33s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m56s
2026-09-20 11:41:10 +00:00
kalana_Thilakarathna 2a5dee3fef Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#79) from Dev into production
Reviewed-on: #79
2026-09-20 11:34:43 +00:00
kalana_Thilakarathna 2584ab8715 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m8s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m36s
2026-09-20 11:34:29 +00:00
ImanThiyanga 6b9d191bf8 Merge pull request 'fixes on ci-cd' (#78) from Dev into production
Reviewed-on: #78
2026-09-18 19:32:33 +00:00
ImanThiyanga 283fa21e87 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 10m46s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m22s
2026-09-18 19:32:25 +00:00
ImanThiyanga 97d19d83ab fixes on ci-cd 2026-09-19 01:01:30 +05:30
ImanThiyanga 01d5159d27 Merge pull request 'changed front needs' (#77) from Dev into production
Reviewed-on: #77
2026-09-18 19:21:39 +00:00
ImanThiyanga 80899ed88c Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 10m18s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 11m40s
2026-09-18 19:21:32 +00:00
ImanThiyanga cfe2466fec changed front needs 2026-09-19 00:51:11 +05:30
ImanThiyanga 9a124c8c63 Merge pull request 'fixed ci-cd' (#76) from Dev into production
Reviewed-on: #76
2026-09-18 19:05:55 +00:00
ImanThiyanga 42ab7e5964 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 12m25s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m58s
2026-09-18 19:05:48 +00:00
ImanThiyanga e7735135ca fixed ci-cd 2026-09-19 00:35:05 +05:30
ImanThiyanga f61d1f95b8 Merge pull request 'Dev' (#75) from Dev into production
Reviewed-on: #75
2026-09-18 18:54:54 +00:00
ImanThiyanga f632c5fc99 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m26s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 15m0s
2026-09-18 18:54:46 +00:00
ImanThiyanga 70179e1abc Merge pull request 'new updates' (#74) from new-updates into Dev
Reviewed-on: #74
2026-09-18 18:53:13 +00:00
Sasanka 8c3d62223c new updates 2026-09-19 00:22:53 +05:30
ImanThiyanga e1f19fe413 Merge pull request 'Add backup images for login and background removal' (#73) from UI into Dev
Reviewed-on: #73
2026-09-18 18:43:33 +00:00
Naveen cb6b92d221 Add backup images for login and background removal
- Added loginerps.png.bak (1134 KB) as a backup for the login image.
- Added whithoutbackground.png.bak (3455 KB) as a backup for the image without background.
2026-09-19 00:12:41 +05:30
ImanThiyanga 3f10ae3bca changes fix for production 2026-09-19 00:00:42 +05:30
ImanThiyanga 7274b89b28 Merge pull request 'migrations fix' (#72) from Dev into production
Reviewed-on: #72
2026-09-18 18:23:44 +00:00
ImanThiyanga 61fe1a0e0c Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Successful in 31s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 13m12s
2026-09-18 18:23:35 +00:00
ImanThiyanga 941f20d4c5 migrations fix 2026-09-18 23:52:26 +05:30
kalana_Thilakarathna b06d20e649 Merge pull request 'test to yml' (#71) from Dev into production
Reviewed-on: #71
2026-09-18 05:23:55 +00:00
Kalana B. Thilakarathna 445d323bed test to yml
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 14m24s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m59s
2026-09-18 10:52:17 +05:30
kalana_Thilakarathna 9028255800 Merge pull request 'workflowupdate' (#70) from Dev into production
Reviewed-on: #70
2026-09-18 04:51:58 +00:00
Kalana B. Thilakarathna 1b2f244853 workflowupdate
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 11m21s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m6s
2026-09-18 10:21:36 +05:30
kalana_Thilakarathna 475bcfec1a Merge pull request 'pipeline update' (#69) from Dev into production
Reviewed-on: #69
2026-09-18 04:48:49 +00:00
Kalana B. Thilakarathna 85db26ec82 pipeline update
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 14m30s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m58s
2026-09-18 10:18:13 +05:30
kalana_Thilakarathna baee987d26 Merge pull request 'test' (#68) from Dev into production
Reviewed-on: #68
2026-09-17 08:25:31 +00:00
Kalana B. Thilakarathna f45a59f20d test
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 12m49s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m57s
2026-09-17 13:55:06 +05:30
kalana_Thilakarathna 32304cbaff Merge pull request 'user fix at workflow' (#67) from Dev into production
Reviewed-on: #67
2026-09-17 07:35:02 +00:00
Kalana B. Thilakarathna 2b64bb0ebc user fix at workflow
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m17s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
2026-09-17 13:04:15 +05:30
kalana_Thilakarathna ffe5251236 Merge pull request 'workflow fix' (#66) from Dev into production
Reviewed-on: #66
2026-09-17 07:26:14 +00:00
Kalana B. Thilakarathna d04a7fb3bc workflow fix
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Has been cancelled
2026-09-17 12:55:38 +05:30
kalana_Thilakarathna d76595085e Merge pull request 'workflow fix' (#65) from Dev into production
Reviewed-on: #65
2026-09-17 07:08:22 +00:00
Kalana B. Thilakarathna bad33a8492 workflow fix
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m57s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
2026-09-17 12:38:05 +05:30
kalana_Thilakarathna b0216fca30 Merge pull request 'triger erpo core workflow' (#64) from Dev into production
Reviewed-on: #64
2026-09-17 07:07:24 +00:00
Kalana B. Thilakarathna 49c112d188 triger erpo core workflow 2026-09-17 12:37:04 +05:30
kalana_Thilakarathna c836b57d54 Merge pull request 'gitea work flow update' (#63) from Dev into production
Reviewed-on: #63
2026-09-17 07:06:02 +00:00
Kalana B. Thilakarathna 3a583f80e5 gitea work flow update 2026-09-17 12:28:31 +05:30
ImanThiyanga 83e0335504 Merge pull request 'Dev' (#62) from Dev into production
Reviewed-on: #62
2026-09-17 05:58:25 +00:00
ImanThiyanga 513343b62a Merge pull request 'Revert/ci cd merges' (#61) from revert/ci-cd-merges into Dev
Reviewed-on: #61
2026-09-17 05:57:56 +00:00
ImanThiyanga 256e39ccff Revert "Merge pull request 'Dev' (#58) from Dev into production"
This reverts commit 821e96e011, reversing
changes made to 0ff39e9e58.
2026-09-17 11:25:33 +05:30
ImanThiyanga efb37fce7a Revert "Merge pull request 'Dev' (#60) from Dev into production"
This reverts commit 01fc2582b6, reversing
changes made to 821e96e011.
2026-09-17 11:25:33 +05:30
ImanThiyanga 01fc2582b6 Merge pull request 'Dev' (#60) from Dev into production
Reviewed-on: #60
2026-09-16 18:47:15 +00:00
ImanThiyanga 39a2dc60be Merge pull request 'Feat/ci cd implementation' (#59) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 13m56s
CI/CD - ERP Core / detect (pull_request) Failing after 14m48s
CI/CD - ERP Core / scripts (pull_request) Failing after 12m24s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m15s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m7s
CI/CD - ERP Core / backend (pull_request) Failing after 14m59s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
Reviewed-on: #59
2026-09-16 18:30:42 +00:00
ImanThiyanga 3b87a6e421 Merge branch 'Dev' into feat/ci-cd-implementation
CI/CD - ERP Core / detect (pull_request) Waiting to run
CI/CD - ERP Core / backend (pull_request) Blocked by required conditions
CI/CD - ERP Core / frontend (pull_request) Blocked by required conditions
CI/CD - ERP Core / e2e-typecheck (pull_request) Blocked by required conditions
CI/CD - ERP Core / scripts (pull_request) Blocked by required conditions
CI/CD - ERP Core / ci-ok (pull_request) Blocked by required conditions
CI/CD - ERP Core / Build & Deploy (pull_request) Waiting to run
2026-09-16 18:30:22 +00:00
ImanThiyanga 2a7a44cc27 change in to merge
CI/CD - ERP Core / detect (pull_request) Failing after 13m11s
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m17s
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
2026-09-16 23:48:07 +05:30
ImanThiyanga c7cf63e7de initiating connection fixes 2026-09-16 23:47:47 +05:30
ImanThiyanga d1044e4728 final fix 2026-09-16 13:53:57 +05:30
ImanThiyanga 821e96e011 Merge pull request 'Dev' (#58) from Dev into production
Reviewed-on: #58
2026-09-16 06:37:25 +00:00
ImanThiyanga d6f973202d Merge branch 'production' into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 10m38s
CI/CD - ERP Core / detect (pull_request) Failing after 11m30s
CI/CD - ERP Core / scripts (pull_request) Failing after 11m31s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 12m23s
CI/CD - ERP Core / frontend (pull_request) Failing after 13m14s
CI/CD - ERP Core / backend (pull_request) Failing after 14m6s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m59s
2026-09-16 06:36:19 +00:00
ImanThiyanga 1d5a5cf68e Merge pull request 'Feat/ci cd implementation' (#57) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / detect (pull_request) Has been cancelled
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Has been cancelled
Reviewed-on: #57
2026-09-16 06:24:55 +00:00
ImanThiyanga 8e472ac996 initialising for ci-cd pipeline
CI/CD - ERP Core / scripts (pull_request) Failing after 12m21s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m13s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m5s
CI/CD - ERP Core / backend (pull_request) Failing after 14m57s
CI/CD - ERP Core / detect (pull_request) Failing after 13m42s
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m47s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
2026-09-16 11:53:55 +05:30
ImanThiyanga 666979cf22 initiated ci-cd pipeline for the erp core with fronend changes to detect changes on both front and back 2026-09-16 11:53:17 +05:30
ashan_rusiru eb36e0acfa Merge pull request 'Users and employees arrangements' (#56) from users-and-employees-arrangements into Dev
Reviewed-on: #56
2026-09-14 05:23:25 +00:00
ashan_rusiru 0ff39e9e58 Merge pull request 'Users and employees arrangements' (#53) from users-and-employees-arrangements into production
Reviewed-on: #53
2026-09-13 07:52:34 +00:00
ashan_rusiru cbb610fc8b Merge branch 'production' into users-and-employees-arrangements 2026-09-13 07:52:24 +00:00
ashan_rusiru afa9928dcc Merge pull request 'bug fixing' (#55) from bug-fixing into Dev
Reviewed-on: #55
2026-09-13 07:52:06 +00:00
Sasanka 99aa395fb1 updeted 2026-09-13 08:30:18 +05:30
Sasanka cb41d503c4 emloyee and user fixings 2026-09-11 10:32:47 +05:30
Sasanka 629f3d1f44 bug fixing 2026-09-09 14:38:53 +05:30
ImanThiyanga b3b6a25ce8 Merge pull request 'Branch crud' (#52) from branch-crud into Dev
Reviewed-on: #52
2026-09-08 07:13:47 +00:00
Sasanka f664961fce branch crud and intergration 2026-09-08 12:13:49 +05:30
Sasanka bebec10eb3 feat: add purchase invoice batch print page and related components
- Implemented PurchaseInvoiceBatchPrintPage for printing purchase invoices.
- Created InvoicePrintHeader and InvoicePrintFooter components for invoice layout.
- Added invoice layout presets for different styles (Classic, Compact, Modern).
- Developed PurchaseInvoicePaymentDialog for handling payments against invoices.
- Introduced API mocks for company profile and invoice template configuration.
- Added validation functions for company profile and purchase invoices.
- Defined types for company profile, invoice template configuration, and purchase invoices.
2026-09-01 15:25:32 +05:30
ashan_rusiru 160c687db3 Merge pull request 'notification module' (#51) from feat/notification-module into Dev
Reviewed-on: #51
2026-08-21 06:40:40 +00:00
Dhananjaya99 d413ad4064 notification module 2026-08-21 12:05:47 +05:30
ImanThiyanga 9b8c2493ad Merge pull request 'feat: add production navigation permissions and produced items page' (#50) from fix/production-costing into Dev
Reviewed-on: #50
2026-08-21 06:32:26 +00:00
ImanThiyanga 99b669395e feat: add production navigation permissions and produced items page
- Created a new migration to add navigation items and permissions for the production section.
- Implemented the Produced Items page with filtering options for date range, templates, warehouses, and status.
- Integrated API calls to fetch produced items, templates, and warehouses.
- Added UI components for displaying produced items in a table format with relevant details.
2026-08-21 11:57:07 +05:30
Dhananjaya99 4cc6a546c7 add day-end and change item model 2026-08-20 14:10:39 +05:30
ImanThiyanga 8835ba9500 Merge pull request 'Refactor code structure for improved readability and maintainability' (#49) from sales-ui-fixers into Dev
Reviewed-on: #49
2026-08-18 06:36:24 +00:00
ImanThiyanga 251b23f4b5 Merge pull request 'feat: enhance stage quantity management for running stock inputs' (#48) from bug-fix-production into Dev
Reviewed-on: #48
2026-08-18 06:35:24 +00:00
Dhananjaya99 283a56723f smal change in general ledger UI 2026-08-18 12:02:26 +05:30
Sasanka 9a38cc1131 Refactor code structure for improved readability and maintainability 2026-08-18 11:54:16 +05:30
Sasanka 1c5b05e315 feat: enhance stage quantity management for running stock inputs
- Updated `UpdateStageQuantitiesRequest` to include `reasonCodeId` for lowering planned quantities below consumed amounts.
- Modified `ProductionRunService` to allow adjustments to planned quantities for running stock inputs, enforcing reason code requirements for decreases.
- Enhanced `StageDrawer` and `StageEditorPanel` components to support reason code selection and display available stock.
- Added utility functions for stock management and improved user feedback for stock adjustments.
2026-08-17 14:30:55 +05:30
ImanThiyanga efa4ad3770 Merge pull request 'Feat/intigrate account service in grn module' (#47) from feat/intigrateAccountServiceInGrnModule into Dev
Reviewed-on: #47
2026-08-17 08:47:24 +00:00
Dhananjaya99 6528fc8d9d intigrate others sales , return and implement day end duntinalities 2026-08-16 23:21:40 +05:30
Dhananjaya99 ae6a87022d make account service with grn 2026-08-15 15:43:01 +05:30
ImanThiyanga ee9fa40edf Merge pull request 'initiated migrations fix in warranty' (#46) from fix/migrations-warrenty into Dev
Reviewed-on: #46
2026-08-13 09:42:02 +00:00
Sasanka b1b0fe4bac initiated migrations fix in warranty 2026-08-13 14:52:11 +05:30
ImanThiyanga b4993b202d Merge pull request 'Dev' (#45) from Dev into production
Reviewed-on: #45
2026-08-13 06:52:56 +00:00
ImanThiyanga 8555702a76 Merge branch 'production' into Dev 2026-08-13 06:52:48 +00:00
ImanThiyanga fd95e92eb1 Merge pull request 'feat: add warranty tracking for items and GRN lines' (#44) from warranty-fileds-and-ui-updates into Dev
Reviewed-on: #44
2026-08-13 06:51:10 +00:00
ImanThiyanga 6e008773db Merge pull request 'feat: add warranty tracking for items and GRN lines' (#43) from warranty-fileds-and-ui-updates into production
Reviewed-on: #43
2026-08-13 06:42:51 +00:00
Sasanka 179d5b0803 feat: add warranty tracking for items and GRN lines
- Enhanced ItemService to include warranty and warranty period months in item DTOs.
- Implemented validation for warranty periods in ItemService.
- Updated frontend item detail and new item pages to handle warranty selection and input.
- Added warranty number handling in GRN creation and detail pages.
- Introduced new GrnLineWarrantyNumber entity to capture warranty numbers for received items.
- Created Warranty enum and associated allowed months for warranty coverage.
- Updated validation logic for GRN lines to ensure warranty numbers are captured correctly.
2026-08-13 12:01:11 +05:30
ImanThiyanga 342012a321 Merge pull request 'Dev' (#42) from Dev into production
Reviewed-on: #42
2026-08-12 19:45:10 +00:00
ImanThiyanga ee6ac913f1 Merge branch 'production' into Dev 2026-08-12 19:44:58 +00:00
ImanThiyanga 2661169351 Merge pull request 'pushing migration changes from now -on' (#41) from fix/migration-issue into Dev
Reviewed-on: #41
2026-08-12 07:05:09 +00:00
ImanThiyanga 80213cf47d pushing migration changes from now -on 2026-08-12 12:32:25 +05:30
ImanThiyanga 18475fdc9f Merge pull request 'feat: add sales return functionality' (#40) from sales-return into Dev
Reviewed-on: #40
2026-08-12 06:28:09 +00:00
ImanThiyanga 9a32c5c609 Merge branch 'Dev' into sales-return 2026-08-12 06:26:49 +00:00
ImanThiyanga 32f40e9d1a Merge pull request 'sku and item fix' (#39) from fix/uom-overall into Dev
Reviewed-on: #39
2026-08-12 05:32:49 +00:00
ImanThiyanga b2a218e2f8 Merge branch 'Dev' into fix/uom-overall 2026-08-12 05:32:39 +00:00
Sasanka d16a227b54 feat: add sales return functionality
- Introduced Sales Return types and interfaces in the frontend for managing sales returns.
- Implemented SalesReturnsController in the backend to handle sales return endpoints.
- Created SalesReturn and SalesReturnLine entities to represent sales return data.
- Added DTOs for sales return responses and requests.
- Configured Entity Framework for SalesReturn and SalesReturnLine entities.
- Developed ISalesReturnService interface and its implementation for business logic.
- Added API methods for listing sales returns, creating new returns, and fetching remaining returnable quantities.
- Created frontend components for creating and listing sales returns, including validation logic.
- Implemented UI for selecting invoices, reason codes, and managing return lines.
2026-08-12 10:18:56 +05:30
ImanThiyanga a7ba3d3e04 sku and item fix 2026-08-11 21:55:04 +05:30
ImanThiyanga 0ae80395cf Merge pull request 'Refactor production and stock tests to remove UOM dependency' (#38) from fix/uom-overall into Dev
Reviewed-on: #38
2026-08-11 06:03:30 +00:00
ImanThiyanga 15ddac178c Refactor production and stock tests to remove UOM dependency
- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases.
- Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation.
- Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only.
- Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities.
- Implemented ItemContent service to validate and normalize content sizes.
- Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations.
- Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
2026-08-11 11:32:40 +05:30
ImanThiyanga d37824cecc fronted fix 2026-08-07 16:40:31 +05:30
ImanThiyanga 271c940640 created new hosted db update 2026-08-07 14:54:35 +05:30
ImanThiyanga f2825900aa Merge pull request 'Fix bundle sale service interface signature' (#36) from test/sales into Dev
Reviewed-on: #36
2026-08-07 06:18:32 +00:00
ImanThiyanga 6520930aeb migrations removed 2026-08-07 11:42:07 +05:30
DeepnaPooja a8c6b4cb5e Fix bundle sale service interface signature 2026-08-07 11:41:52 +05:30
ImanThiyanga 8e9974b735 Merge pull request 'fix: update toast messages and improve UI elements in purchase orders and stock pages' (#34) from ui-fixers-7/8 into Dev
Reviewed-on: #34
2026-08-07 05:17:04 +00:00
Sasanka c4e016c460 fix: update toast messages and improve UI elements in purchase orders and stock pages 2026-08-07 10:46:23 +05:30
ImanThiyanga 7e8418685c Merge pull request 'fix sales issues' (#33) from fix--Sales_issues into Dev
Reviewed-on: #33
2026-08-07 05:13:02 +00:00
DeepnaPooja cbc72ef830 fix sales issues 2026-08-05 17:03:49 +05:30
ImanThiyanga 4324ba1a96 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-08-05 16:16:50 +05:30
ImanThiyanga 1af16d3dec fix: update DefaultConnection string for development environment 2026-08-05 16:16:15 +05:30
ImanThiyanga f140959b43 Merge pull request 'feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments' (#32) from test/rebase into Dev
Reviewed-on: #32
2026-08-05 08:04:42 +00:00
ImanThiyanga c31e23c2b9 feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments
- Introduced Playwright configuration for e2e testing.
- Implemented authentication tests to validate login functionality.
- Created tests for GRN (Goods Receipt Note) to ensure proper stock handling.
- Developed production run tests to verify lifecycle and stock posting.
- Added stock transfer tests to check movement between warehouses.
- Implemented stock adjustment tests for positive and negative adjustments.
- Established API seeder for test data setup and verification.
- Enhanced utility functions for UI interactions and response handling.
2026-08-05 13:26:41 +05:30
ImanThiyanga 7219480ca0 Merge pull request 'Merge pull request 'Fix/general ledger v01' (#30) from fix/general-ledger-v01 into Dev' (#31) from Dev into production
Reviewed-on: #31
2026-08-05 07:22:32 +00:00
ImanThiyanga 8e24ed6375 Merge branch 'production' into Dev 2026-08-05 07:22:25 +00:00
ImanThiyanga 9f22026784 Merge pull request 'Fix/general ledger v01' (#30) from fix/general-ledger-v01 into Dev
Reviewed-on: #30
2026-08-05 07:22:12 +00:00
ImanThiyanga ef105302bd Merge pull request 'fix: map GL response integers to string enums for Cheque Management fields' (#29) from fix/general-ledger-v01 into production
Reviewed-on: #29
2026-08-05 07:20:28 +00:00
ImanThiyanga d7ee83828c Merge branch 'production' into fix/general-ledger-v01 2026-08-05 07:20:23 +00:00
HarithaRandunu eb7b2691df fix: map GL response integers to string enums for Cheque Management fields 2026-08-05 12:45:34 +05:30
ImanThiyanga 1a0fb4603e Merge pull request 'Dev' (#28) from Dev into production
Reviewed-on: #28
2026-08-05 06:59:22 +00:00
ImanThiyanga 5fc5ef59ac Merge pull request 'feat: enhance purchase orders management with delete and approve actions' (#27) from fixers-frontend-8/5 into Dev
Reviewed-on: #27
2026-08-05 06:58:46 +00:00
Sasanka d1fe164ea2 fix: resolve import conflict in purchase order detail page 2026-08-05 12:21:01 +05:30
Sasanka 02f47bd485 Merge branch 'fixers-frontend-8/5' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into fixers-frontend-8/5 2026-08-05 12:15:28 +05:30
Sasanka 45554ceb9a feat: enhance purchase orders management with delete and approve actions
- Added delete and approve functionality for purchase orders in the procurement dashboard.
- Integrated toast notifications for user feedback on actions.
- Updated the purchase orders table to include action buttons for viewing, editing, approving, and deleting orders.

refactor: improve stage editor panel UI and functionality

- Refactored StageEditorPanel to use a dialog for better user experience.
- Enhanced input handling and added custom fields management.
- Improved layout and organization of inputs and outputs sections.

fix: streamline template builder page layout

- Adjusted layout of the template builder page for better responsiveness and usability.
- Ensured the StageEditorPanel is displayed correctly when a node is selected.

feat: implement vendor code generation logic

- Created a new utility function to generate unique vendor codes based on the vendor name.
- Updated the vendor creation form to auto-generate vendor codes and display them to the user.
- Removed manual vendor code input requirement, improving user experience and reducing errors.
2026-08-05 12:14:08 +05:30
Sasanka 5d0ea3f035 feat: enhance purchase orders management with delete and approve actions
- Added delete and approve functionality for purchase orders in the procurement dashboard.
- Integrated toast notifications for user feedback on actions.
- Updated the purchase orders table to include action buttons for viewing, editing, approving, and deleting orders.

refactor: improve stage editor panel UI and functionality

- Refactored StageEditorPanel to use a dialog for better user experience.
- Enhanced input handling and added custom fields management.
- Improved layout and organization of inputs and outputs sections.

fix: streamline template builder page layout

- Adjusted layout of the template builder page for better responsiveness and usability.
- Ensured the StageEditorPanel is displayed correctly when a node is selected.

feat: implement vendor code generation logic

- Created a new utility function to generate unique vendor codes based on the vendor name.
- Updated the vendor creation form to auto-generate vendor codes and display them to the user.
- Removed manual vendor code input requirement, improving user experience and reducing errors.
2026-08-05 10:56:05 +05:30
DeepnaPooja 26cf2a146a Merge pull request 'centralize sales flow' (#26) from fix-sales-module into Dev
Reviewed-on: #26
2026-08-04 11:58:10 +00:00
DeepnaPooja 0750773f94 centralize sales flow 2026-08-04 17:19:30 +05:30
ImanThiyanga 38c7545413 removing migrations 2026-08-04 16:14:12 +05:30
DeepnaPooja 0d60aeef64 Merge pull request 'Enhance/sales' (#25) from enhance/sales into Dev
Reviewed-on: #25
2026-08-04 06:06:31 +00:00
DeepnaPooja c6bc8065a2 DB issue fix 2026-08-04 11:27:39 +05:30
DeepnaPooja 4f56d481a2 Add bundle sales front end integration 2026-08-04 11:18:32 +05:30
DeepnaPooja d79371697e Add bundle sales backend implementation 2026-08-04 11:17:21 +05:30
ImanThiyanga f7a65b5f7e fixed dbContext 2026-08-03 12:18:44 +05:30
ashan_rusiru 6b216195c6 Merge pull request 'Feat/sales management' (#24) from feat/sales-management into Dev
Reviewed-on: #24
2026-08-02 04:56:51 +00:00
DeepnaPooja 266a2a2c14 implement fe with backend 2026-08-02 01:25:17 +05:30
DeepnaPooja 3c5b476635 sales ,invoice ,slip and free issues BE fixes 2026-08-02 01:12:09 +05:30
DeepnaPooja 59af50bf11 Add sales slip document 2026-08-02 01:07:01 +05:30
DeepnaPooja 0b9d64f911 Add sales invoice document 2026-08-02 01:05:09 +05:30
DeepnaPooja 6258ebd8de implemnet customer master used for both B2B and B2C. 2026-08-02 00:59:16 +05:30
ImanThiyanga 37c8da2ced removed all migration updated 2026-08-01 08:45:10 +05:30
ImanThiyanga b7f9f599eb Merge pull request 'Feat/sales management' (#23) from feat/sales-management into Dev
Reviewed-on: #23
2026-08-01 01:47:27 +00:00
ImanThiyanga f43a8a8486 Merge pull request 'Feat/general ledger service' (#22) from feat/general-ledger-service into Dev
Reviewed-on: #22
2026-08-01 01:46:45 +00:00
HarithaRandunu a271d1832e fix: resolve repo-wide bug with missing HRM and Manufacturing tables in EF model
- Identified and fixed a critical issue where 36 tables existed in the EF model but were absent in the actual database.
- Root cause traced to a `.gitignore` rule that prevented migration files from being tracked, leading to discrepancies between the model snapshot and the database.
- Removed problematic entities from the snapshot, generated a migration to add the missing tables, and confirmed successful application.
- Reverted the `.gitignore` rule to ensure future migrations are tracked properly.
- No changes to existing tables, ensuring no collateral schema drift occurred.
2026-07-31 18:57:32 +05:30
ImanThiyanga 6ebdbb655a changes for db 2026-07-31 18:11:40 +05:30
HarithaRandunu 22657f0910 feat: Implement new General Ledger frontend section with comprehensive report screens and cash/bank account management
- Added a new Ledgers sidebar section for statutory-format financial reports and cash/bank-account management.
- Introduced dedicated GL client for API interactions, handling response envelopes and error management.
- Developed report screens for Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, and a new Tax Report.
- Implemented CSV download functionality alongside existing PDF downloads for all report screens.
- Separated Cash and Bank accounts into distinct tables/endpoints, with updated create forms and unified list view.
- Created a new Accounts section for Cheque Management, moving Cash/Bank Accounts from the Ledgers section.
- Updated RBAC navigation to include new permissions and sub-navigation items for the added features.
- Ensured compliance with GL's updated API contract, including renaming fields and adjusting response shapes.
- Addressed various bugs and presentation issues, enhancing user experience across the new module.
2026-07-31 18:02:03 +05:30
DeepnaPooja 74d3e684d2 Add sales report functions 2026-07-31 17:59:06 +05:30
DeepnaPooja 2dab7051b3 Add discoont rule 2026-07-31 17:59:05 +05:30
DeepnaPooja 4f722432cd add shared pricing resolution with fixed yem price,GRN weighted and FIFO valuation cost fallback 2026-07-31 17:59:05 +05:30
DeepnaPooja 1f9e12e84b Add sales slip document 2026-07-31 17:59:05 +05:30
DeepnaPooja 8b8e79e0fe Add sales invoice document 2026-07-31 17:59:05 +05:30
DeepnaPooja ffbd47f6f9 implemnet customer master used for both B2B and B2C. 2026-07-31 17:58:14 +05:30
Kalana B. Thilakarathna a414dfc4ea graphify update 2026-07-31 12:01:42 +05:30
Kalana B. Thilakarathna 3cccaf4c63 graphify added 2026-07-31 11:53:20 +05:30
ImanThiyanga 76484c7268 Merge pull request 'Refactor: Remove unused imports and clean up code across multiple pages' (#21) from fix/frontend-changers into Dev
Reviewed-on: #21
2026-07-31 05:42:56 +00:00
Sasanka 96f81cb03a Refactor: Remove unused imports and clean up code across multiple pages
- Removed unused Link imports and associated code for navigation in various pages.
- Simplified TableHeader components by removing unnecessary class names.
- Updated alert dialog styles for better consistency with the design system.
- Cleaned up imports in several components to streamline the codebase.
2026-07-31 11:04:32 +05:30
ImanThiyanga b7bd8dca5c Merge pull request 'Dev' (#20) from Dev into production
Reviewed-on: #20
2026-07-31 05:24:39 +00:00
ImanThiyanga cb3f1e5cde Merge branch 'production' into Dev 2026-07-31 05:24:32 +00:00
ImanThiyanga c3fef88bcf Merge pull request 'feat: add production templates API and documentation for manufacturing phase 2' (#19) from feat/production-integration into Dev
Reviewed-on: #19
2026-07-31 04:54:00 +00:00
ImanThiyanga 7d6e597389 feat: add production templates API and documentation for manufacturing phase 2
- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates.
- Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs.
- Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
2026-07-31 10:22:40 +05:30
ImanThiyanga 415ac94ab2 Merge pull request 'Dashborad and prouction' (#18) from Dashborad-and-prouction into Dev
Reviewed-on: #18
2026-07-29 17:08:57 +00:00
Sasanka 4561ef7ba8 feat: implement frontend-only mock for production lines and runs
- Add ProductionTemplatesPage component for managing production templates with a visual representation using React Flow.
- Create LineHeaderNode and LineStageNode components for rendering production line nodes.
- Introduce RunStageNode and RunHeaderNode components for displaying run stages and headers.
- Implement StageProgressStrip for visualizing stage progress in runs.
- Create mock data for production runs and templates to simulate backend functionality.
- Define types for production templates and runs to structure mock data.
- Document frontend Phase 2 specifications for manufacturing processes, including screens, dialogs, and validation posture.
2026-07-28 22:55:56 +05:30
Sasanka b12adebaa0 feat: add dashboard overview stats endpoint and UI integration
- Implemented `GET /dashboard/stats` in `DashboardController` to provide aggregate counts for stock, GRN, and procurement.
- Created `DashboardStatsDto` and `WarehouseValuationDto` to structure the response data.
- Developed `DashboardService` to fetch and compute necessary statistics from the database.
- Added `IDashboardService` interface for service abstraction.
- Introduced API client methods in `dashboard.ts` for frontend consumption of the new endpoint.
- Defined TypeScript types for dashboard data in `dashboard.ts` to ensure type safety in the frontend.
- Updated UI components in the frontend to reflect changes in the dashboard, including styling adjustments and removal of unused icons.
2026-07-28 14:59:09 +05:30
ImanThiyanga 0b95d6f1cd Merge pull request 'feat: enhance UI components and add vibrant theme support' (#17) from ui-fixers into Dev
Reviewed-on: #17
2026-07-27 06:10:10 +00:00
ImanThiyanga d35b076435 Merge branch 'Dev' into ui-fixers 2026-07-27 06:10:00 +00:00
Sasanka c564916c60 feat: enhance UI components and add vibrant theme support
- Updated layout and styling for various dashboard pages including stock adjustments, counts, transfers, wastage, and vendors to improve responsiveness and visual consistency.
- Introduced a vibrant theme option in the theme toggle component, allowing users to switch between light, dark, and vibrant themes.
- Refactored button styles across login and forgot password pages for better accessibility and visual feedback.
- Improved sidebar navigation with expanded functionality for items without dedicated pages.
- Enhanced data table styling for better readability and user interaction.
- Added new background images to support the vibrant theme.
2026-07-27 11:11:26 +05:30
Dhananjaya99 86bb4d4908 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-07-24 14:33:55 +05:30
Dhananjaya99 816ffbbfb6 setting not found issue 2026-07-24 14:33:49 +05:30
ashan_rusiru ae20bc4e34 Merge pull request 'develop full initial module' (#16) from Feature_HRM into Dev
Reviewed-on: #16
2026-07-24 05:25:13 +00:00
Dhananjaya99 755df494fe develop full initial module 2026-07-23 19:54:56 +05:30
Dhananjaya99 eacc21afad Merge branch 'fixers-7/23' into Dev 2026-07-23 13:02:24 +05:30
ImanThiyanga 5af77fbf0a Merge pull request 'feat: Implement fixed sale price functionality for items' (#14) from fix/grn-varient into Dev
Reviewed-on: #14
2026-07-23 06:48:46 +00:00
ImanThiyanga a1b3985469 feat: Implement fixed sale price functionality for items
- Added a toggle for fixed sale price vs stock value in the item creation form.
- Introduced validation to ensure all variants have a price greater than 0 when fixed price mode is selected.
- Updated the item model to include a nullable salePrice field, which is used for sales only and does not affect GRN/FIFO/ledger.
- Enhanced the GRN page to allow off-PO items and included a refresh button to update the item list without reloading the page.
- Updated documentation to reflect changes in item pricing and GRN handling.
2026-07-23 12:12:32 +05:30
ImanThiyanga 8484601494 Merge pull request 'Dev' (#13) from Dev into production
Reviewed-on: #13
2026-07-22 06:12:34 +00:00
890 changed files with 386004 additions and 20549 deletions
Submodule .claude/worktrees/agent-a98805dc618e0f67c added at b4993b202d
+92
View File
@@ -0,0 +1,92 @@
name: CI/CD - ERP Core Monorepo
on:
pull_request:
branches:
- production
types:
- closed
jobs:
build-and-deploy-backend:
name: Build & Deploy Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Restore
run: dotnet restore Backend/ERPCore.sln
- name: Publish
run: |
rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy
dotnet publish Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpcore-deploy
- name: Stop service
run: systemctl stop erpcore.service
- name: Copy to deploy folder
run: |
mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Fix ownership and permissions
run: |
chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
- name: Restart service
run: systemctl restart erpcore.service
- name: Verify
run: |
systemctl is-active erpcore.service && echo "✅ Service running" || echo "❌ Service failed"
build-and-deploy-frontend:
name: Build & Deploy Frontend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Build Docker image
run: |
docker build --progress=plain --no-cache \
-t erpcore-frontend:latest \
./Frontend/erp-system
- name: Stop & remove existing container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start container
run: |
docker run -d \
--name erpcore-frontend \
-p 6702:6702 \
--add-host=host.docker.internal:host-gateway \
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpcore-frontend:latest
- name: Remove old images
run: docker image prune -f
- name: Verify
run: |
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
+7 -6
View File
@@ -30,9 +30,10 @@ yarn-error.log*
Thumbs.db
.idea/
# ── Migrations ─────────────────────────────────────────────────────────
# New EF Core migrations are not committed. Note the 4 migrations already in
# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does
# not apply to tracked files — so edits to those still get committed as normal.
# Untracking them too takes `git rm --cached`.
**/Migrations/
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
Testing/e2e/playwright-report/
Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ERPCore\ERPCore.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,152 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Notifications;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Tests;
public class NotificationConfigServiceTests
{
[Fact]
public async Task GetAsync_MergesSavedValuesWithDiscoveredModules()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = false,
InAppEnabled = true
}
});
var service = CreateService(db);
var result = await service.GetAsync();
Assert.Equal(7u, result.RowVersion);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
}
[Fact]
public async Task UpdateAsync_PersistsEmptyAndMultiChannelSelections()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db);
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select((module, index) => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = index == 0,
SmsEnabled = index == 1,
InAppEnabled = index == 1
}).ToList()
};
var updated = await service.UpdateAsync(request, 7);
Assert.Equal(7u, updated.RowVersion);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
await using var verifyDb = NotificationConfigTestSupport.CreateContext(dbName);
var persisted = await verifyDb.NotificationConfigModules.AsNoTracking().ToListAsync();
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsEmptyWhenNothingEnabled()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Empty(channels);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsMultipleChannels()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = true,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Equal(new[] { NotificationChannel.Email, NotificationChannel.Sms }, channels);
}
[Fact]
public async Task UpdateAsync_MapsConcurrencyFailuresToDomainException()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db, new ThrowingUnitOfWork());
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select(module => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}).ToList()
};
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 7));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
private static NotificationConfigService CreateService(ERPCore.Infra.Persistence.ErpDbContext db, ERPCore.Infra.UoW.IUnitOfWork? uow = null)
{
var configRepo = new Repository<NotificationConfig>(db);
var moduleRepo = new Repository<NotificationConfigModule>(db);
return new NotificationConfigService(
configRepo,
moduleRepo,
new NotificationModuleCatalog(),
new TestCurrentUser(),
uow ?? new ERPCore.Infra.UoW.UnitOfWork(db));
}
}
@@ -0,0 +1,17 @@
using ERPCore.Services;
namespace ERPCore.Tests;
public class NotificationModuleCatalogTests
{
[Fact]
public void GetAll_ReturnsStableUniqueModuleKeys()
{
var catalog = new NotificationModuleCatalog();
Assert.NotEmpty(catalog.GetAll());
Assert.Contains(catalog.GetAll(), m => m.Key == "sale.create");
Assert.Contains(catalog.GetAll(), m => m.Key == "grn.confirmed");
Assert.Equal(catalog.GetAll().Count, catalog.GetAll().Select(m => m.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
}
@@ -0,0 +1,70 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests.TestSupport;
internal sealed class TestCurrentUser : ICurrentUser
{
public string UserId => "system";
public int AuditUserId => User.SystemUserId;
public bool IsAuthenticated => false;
}
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
=> throw new NotSupportedException();
}
internal static class NotificationConfigTestSupport
{
private static readonly InMemoryDatabaseRoot Root = new();
public static ErpDbContext CreateContext(string name)
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(name, Root)
.Options;
return new ErpDbContext(options, new TestCurrentUser());
}
public static async Task SeedAsync(ErpDbContext db, IEnumerable<NotificationConfigModule>? modules = null)
{
db.Users.Add(new User
{
UserId = User.SystemUserId,
Username = "system",
DisplayName = "System"
});
var config = new NotificationConfig
{
NotificationConfigId = NotificationConfig.SingletonId,
UpdatedBy = User.SystemUserId,
UpdatedAt = DateTime.UtcNow,
RowVersion = 7,
};
if (modules is not null)
{
foreach (var module in modules)
{
module.NotificationConfigId = NotificationConfig.SingletonId;
config.Modules.Add(module);
}
}
db.NotificationConfig.Add(config);
await db.SaveChangesAsync();
}
}
@@ -27,13 +27,15 @@ public sealed class AuthController : ControllerBase
private readonly IAuthRecoveryService _recovery;
private readonly IAuthAltService _alt;
private readonly IRoleService _roles;
private readonly IAuditService _audit;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
_audit = audit;
}
/// <summary>
@@ -68,6 +70,7 @@ public sealed class AuthController : ControllerBase
{
var result = await _users.LoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body);
}
@@ -78,9 +81,13 @@ public sealed class AuthController : ControllerBase
{
var result = await _users.VerifyOtpForLoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body);
}
private string? ClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
private string? ClientUserAgent() => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null;
[HttpPost("refresh-token")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Branch configuration endpoints — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.
/// <para>
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only, same
/// Phase-1 posture as <see cref="ProductConfigController"/> — any ERP-admitted user can
/// flip this flag until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/branch-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class BranchConfigController : ApiControllerBase
{
private readonly IBranchConfigService _config;
public BranchConfigController(IBranchConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BranchConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BranchConfigDto>> Update(
[FromBody] UpdateBranchConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Brands;
using ERPCore.Dtos.Common;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
[Route("api/v1/brands")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class BrandsController : ApiControllerBase
{
private readonly IBrandService _brands;
@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/bundle-sales")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class BundleSalesController : ApiControllerBase
{
private readonly IBundleSaleService _bundles;
public BundleSalesController(IBundleSaleService bundles) => _bundles = bundles;
[HttpGet("templates")]
[ProducesResponseType(typeof(PagedResponse<BundleSaleTemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleTemplateSummaryDto>>> ListTemplates(
[FromQuery] PageQuery query,
CancellationToken ct)
=> Ok(await _bundles.ListTemplatesAsync(query, ct));
[HttpGet("templates/{bundleSaleTemplateId:int}")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleSaleTemplateDto>> GetTemplate(int bundleSaleTemplateId, CancellationToken ct)
{
var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("templates")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleTemplateDto>> CreateTemplate([FromBody] CreateBundleSaleTemplateRequest request, CancellationToken ct)
{
var result = await _bundles.CreateTemplateAsync(request, ct);
return Created($"/api/v1/bundle-sales/templates/{result.BundleSaleTemplateId}", result);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] BundleSaleStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BundleSaleDto>> GetById(int bundleSaleId, CancellationToken ct)
{
var result = await _bundles.GetAsync(bundleSaleId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{bundleSaleId:int}/posting-check")]
[ProducesResponseType(typeof(BundleSalePostingCheckDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSalePostingCheckDto>> PostingCheck(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct));
[HttpPost]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleDto>> Create([FromBody] CreateBundleSaleRequest request, CancellationToken ct)
{
var result = await _bundles.CreateAsync(request, ct);
return Created($"/api/v1/bundle-sales/{result.BundleSaleId}", result);
}
[HttpPut("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Update(int bundleSaleId, [FromBody] UpdateBundleSaleRequest request, CancellationToken ct)
{
return Ok(await _bundles.UpdateAsync(bundleSaleId, request, ct));
}
[HttpPost("{bundleSaleId:int}/post")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Post(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.PostAsync(bundleSaleId, ct));
[HttpPost("{bundleSaleId:int}/cancel")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BundleSaleDto>> Cancel(int bundleSaleId, CancellationToken ct)
=> Ok(await _bundles.CancelAsync(bundleSaleId, ct));
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
/// </summary>
[Route("api/v1/categories")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class CategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Customers;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
[Route("api/v1/customers")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class CustomersController : ApiControllerBase
{
private readonly ICustomerService _customers;
public CustomersController(ICustomerService customers) => _customers = customers;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CustomerDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CustomerDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct)
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CustomerDto>> GetById(int customerId, CancellationToken ct)
{
var result = await _customers.GetAsync(customerId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CustomerDto>> Create([FromBody] CreateCustomerRequest request, CancellationToken ct)
{
var result = await _customers.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/customers/{result.Value.CustomerId}", result.Value);
}
[HttpPut("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CustomerDto>> Update(int customerId, [FromBody] UpdateCustomerRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _customers.UpdateAsync(customerId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{customerId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
{
await _customers.SetStatusAsync(customerId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Dashboard;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
[Route("api/v1/dashboard")]
[Authorize(Policy = NavPolicies.Prefix + "dashboard")]
public sealed class DashboardController : ApiControllerBase
{
private readonly IDashboardService _dashboard;
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
}
@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Thin API alias for free-issue handling. Free issues are modeled as sales slips
/// with line-level <c>IsFreeIssue</c>/<c>FreeQty</c> flags, so this controller reuses
/// the existing sales-slip CRUD surface under a more business-friendly route.
/// </summary>
[Route("api/v1/free-issues")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class FreeIssuesController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
public FreeIssuesController(ISalesSlipService slips) => _slips = slips;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<FreeIssueSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<FreeIssueSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesSlipStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _slips.ListFreeIssuesAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{freeIssueId:int}")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<FreeIssueDto>> GetById(int freeIssueId, CancellationToken ct)
{
var result = await _slips.GetFreeIssueAsync(freeIssueId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{freeIssueId:int}/posting-check")]
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.CheckPostingAsync(freeIssueId, ct));
[HttpPost]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status201Created)]
public async Task<ActionResult<FreeIssueDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
{
var result = await _slips.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/free-issues/{result.Value.SalesSlipId}", result.Value);
}
[HttpPut("{freeIssueId:int}")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<FreeIssueDto>> Update(int freeIssueId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _slips.UpdateAsync(freeIssueId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{freeIssueId:int}/post")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
public async Task<ActionResult<FreeIssueDto>> Post(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.PostAsync(freeIssueId, ct));
[HttpPost("{freeIssueId:int}/cancel")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
public async Task<ActionResult<FreeIssueDto>> Cancel(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.CancelAsync(freeIssueId, ct));
}
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Generic reverse proxy into the external General Ledger service — forwards every
/// method/path/query/body under this prefix verbatim via
/// <see cref="IGeneralLedgerService"/> and returns GL's response (status, content-type,
/// body) unchanged. No endpoint-specific shape lives here; see
/// docs/12-GENERAL-LEDGER-INTEGRATION.md for the full GL contract and what this proxy
/// does and doesn't do. Gated by the same ERP door policy as every other v1 endpoint
/// (<see cref="ApiControllerBase"/>) — the shared GL API key is attached server-side
/// only and is never exposed to the frontend.
/// </summary>
[Route("api/v1/gl")]
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
public sealed class GeneralLedgerController : ApiControllerBase
{
private readonly IGeneralLedgerService _gl;
public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl;
[HttpGet("{**path}")]
public Task<IActionResult> Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct);
[HttpPost("{**path}")]
public Task<IActionResult> Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct);
[HttpPut("{**path}")]
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
{
var body = method == HttpMethod.Get ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult
{
StatusCode = result.StatusCode,
Content = result.Body,
ContentType = result.ContentType ?? "application/json"
};
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
@@ -8,19 +10,23 @@ namespace ERPCore.Controllers;
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
[Route("api/v1/grns")]
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
public sealed class GrnsController : ApiControllerBase
{
private readonly IGrnService _grns;
public GrnsController(IGrnService grns) => _grns = grns;
public GrnsController(IGrnService grns)
{
_grns = grns;
}
/// <summary>List GRNs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
@@ -0,0 +1,99 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
[Route("api/v1/attendance-batches")]
public sealed class AttendanceBatchesController : ApiControllerBase
{
private readonly IAttendanceUploadService _attendance;
private readonly ICurrentUser _currentUser;
public AttendanceBatchesController(IAttendanceUploadService attendance, ICurrentUser currentUser)
{
_attendance = attendance;
_currentUser = currentUser;
}
[HttpGet("template.xlsx")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult DownloadTemplate([FromQuery] string? format)
{
var (content, contentType, fileName) = _attendance.GenerateTemplate(string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase));
return File(content, contentType, fileName);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AttendanceUploadBatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AttendanceUploadBatchDto>>> List(
[FromQuery] PageQuery query, [FromQuery] AttendanceBatchStatus? status,
[FromQuery] int? periodYear, [FromQuery] int? periodMonth, CancellationToken ct)
=> Ok(await _attendance.ListBatchesAsync(query, status, periodYear, periodMonth, ct));
[HttpGet("{batchId:int}")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AttendanceUploadBatchDto>> GetById(int batchId, CancellationToken ct)
{
var result = await _attendance.GetBatchAsync(batchId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Upload(
[FromForm] UploadAttendanceBatchMetadata metadata, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _attendance.UploadAsync(stream, file.FileName, metadata.PeriodStart, metadata.PeriodEnd, _currentUser.AuditUserId, ct);
return Created($"/api/v1/attendance-batches/{result.AttendanceUploadBatchId}", result);
}
[HttpGet("{batchId:int}/records")]
[ProducesResponseType(typeof(List<AttendanceRecordDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AttendanceRecordDto>>> ListRecords(
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
[HttpPut("{batchId:int}/records/{recordId:int}")]
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceRecordDto>> UpdateRecord(
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
[HttpPost("{batchId:int}/resolve-duplicate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResolveDuplicate(int batchId, [FromBody] ResolveDuplicateRequest request, CancellationToken ct)
{
await _attendance.ResolveDuplicateAsync(batchId, request, ct);
return NoContent();
}
[HttpPost("{batchId:int}/validate")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
=> Ok(await _attendance.ValidateAsync(batchId, ct));
[HttpPost("{batchId:int}/confirm")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
[HttpPost("{batchId:int}/unlock")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Unlock(int batchId, [FromBody] UnlockAttendanceBatchRequest request, CancellationToken ct)
=> Ok(await _attendance.UnlockAsync(batchId, request.Reason, _currentUser.AuditUserId, ct));
}
@@ -0,0 +1,74 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Branch master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/branches")]
public sealed class BranchesController : ApiControllerBase
{
private readonly IBranchService _branches;
public BranchesController(IBranchService branches) => _branches = branches;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BranchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BranchDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _branches.ListAsync(query, status, ct));
[HttpGet("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BranchDto>> GetById(int branchId, CancellationToken ct)
{
var result = await _branches.GetAsync(branchId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BranchDto>> Create([FromBody] CreateBranchRequest request, CancellationToken ct)
{
var result = await _branches.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
}
[HttpPut("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BranchDto>> Update(int branchId, [FromBody] UpdateBranchRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _branches.UpdateAsync(branchId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{branchId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int branchId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent();
}
[HttpDelete("{branchId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int branchId, CancellationToken ct)
{
await _branches.DeleteAsync(branchId, ct);
return NoContent();
}
}
@@ -0,0 +1,75 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/departments")]
public sealed class DepartmentsController : ApiControllerBase
{
private readonly IDepartmentService _departments;
public DepartmentsController(IDepartmentService departments) => _departments = departments;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DepartmentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<DepartmentDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _departments.ListAsync(query, status, ct));
[HttpGet("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DepartmentDto>> GetById(int departmentId, CancellationToken ct)
{
var result = await _departments.GetAsync(departmentId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DepartmentDto>> Create([FromBody] CreateDepartmentRequest request, CancellationToken ct)
{
var result = await _departments.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
}
[HttpPut("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<DepartmentDto>> Update(int departmentId, [FromBody] UpdateDepartmentRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _departments.UpdateAsync(departmentId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{departmentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int departmentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent();
}
[HttpDelete("{departmentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int departmentId, CancellationToken ct)
{
await _departments.DeleteAsync(departmentId, ct);
return NoContent();
}
}
@@ -0,0 +1,74 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/designations")]
public sealed class DesignationsController : ApiControllerBase
{
private readonly IDesignationService _designations;
public DesignationsController(IDesignationService designations) => _designations = designations;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DesignationDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<DesignationDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _designations.ListAsync(query, status, ct));
[HttpGet("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DesignationDto>> GetById(int designationId, CancellationToken ct)
{
var result = await _designations.GetAsync(designationId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DesignationDto>> Create([FromBody] CreateDesignationRequest request, CancellationToken ct)
{
var result = await _designations.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
}
[HttpPut("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<DesignationDto>> Update(int designationId, [FromBody] UpdateDesignationRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _designations.UpdateAsync(designationId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{designationId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int designationId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent();
}
[HttpDelete("{designationId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int designationId, CancellationToken ct)
{
await _designations.DeleteAsync(designationId, ct);
return NoContent();
}
}
@@ -0,0 +1,227 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>
/// Employee (staff) endpoints, incl. the Employee&lt;-&gt;User cross-link and staff
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
/// </summary>
[Route("api/v1/employees")]
public sealed class EmployeesController : ApiControllerBase
{
private readonly IEmployeeService _employees;
private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeDocumentService _documents;
private readonly ILeaveBalanceService _leaveBalances;
private readonly IEmployeeSalaryStructureService _salaryStructures;
private readonly IEmployeeLoanService _loans;
private readonly ICurrentUser _currentUser;
public EmployeesController(
IEmployeeService employees, IEmployeeUserLinkService links, IEmployeeDocumentService documents,
ILeaveBalanceService leaveBalances, IEmployeeSalaryStructureService salaryStructures,
IEmployeeLoanService loans, ICurrentUser currentUser)
{
_employees = employees;
_links = links;
_documents = documents;
_leaveBalances = leaveBalances;
_salaryStructures = salaryStructures;
_loans = loans;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<EmployeeListItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<EmployeeListItemDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EmployeeStatus? status,
[FromQuery] int? departmentId, [FromQuery] int? designationId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
[HttpGet("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeDetailDto>> GetById(int employeeId, CancellationToken ct)
{
var result = await _employees.GetAsync(employeeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeDetailDto>> Create([FromBody] CreateEmployeeRequest request, CancellationToken ct)
{
var result = await _employees.CreateAsync(request, _currentUser.AuditUserId, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
}
[HttpPut("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<EmployeeDetailDto>> Update(int employeeId, [FromBody] UpdateEmployeeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _employees.UpdateAsync(employeeId, request, expected, _currentUser.AuditUserId, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int employeeId, [FromBody] UpdateEmployeeStatusRequest request, CancellationToken ct)
{
await _employees.SetStatusAsync(employeeId, request.Status, ct);
return NoContent();
}
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
[HttpDelete("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeDeleteResultDto>> Delete(int employeeId, [FromQuery] bool deleteUserAccount, CancellationToken ct)
{
var result = await _employees.DeleteAsync(employeeId, deleteUserAccount, ct);
return Ok(result);
}
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> LinkUser(int employeeId, [FromBody] LinkUserRequest request, CancellationToken ct)
{
await _links.LinkAsync(employeeId, request.UserId, ct);
return NoContent();
}
[HttpDelete("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UnlinkUser(int employeeId, CancellationToken ct)
{
await _links.UnlinkAsync(employeeId, ct);
return NoContent();
}
[HttpGet("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
[HttpPut("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ReplaceBankDetails(
int employeeId, [FromBody] ReplaceEmployeeBankDetailsRequest request, CancellationToken ct)
=> Ok(await _employees.ReplaceBankDetailsAsync(employeeId, request, ct));
[HttpGet("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
[HttpPut("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<LeaveBalanceDto>>> UpdateLeaveBalances(
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
[HttpGet("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
[HttpPost("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeSalaryStructureDto>> CreateSalaryStructure(
int employeeId, [FromBody] CreateSalaryStructureRequest request, CancellationToken ct)
{
var result = await _salaryStructures.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
}
[HttpGet("{employeeId:int}/loans")]
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
=> Ok(await _loans.ListAsync(employeeId, ct));
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeLoanDto>> GetLoan(int employeeId, int loanId, CancellationToken ct)
{
var result = await _loans.GetAsync(employeeId, loanId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{employeeId:int}/loans")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeLoanDto>> CreateLoan(int employeeId, [FromBody] CreateEmployeeLoanRequest request, CancellationToken ct)
{
var result = await _loans.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
}
[HttpGet("{employeeId:int}/documents")]
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
=> Ok(await _documents.ListAsync(employeeId, ct));
[HttpPost("{employeeId:int}/documents")]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<EmployeeDocumentDto>> UploadDocument(
int employeeId, [FromForm] UploadEmployeeDocumentRequest request, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _documents.UploadAsync(
employeeId, request, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
}
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadDocument(int employeeId, int documentId, CancellationToken ct)
{
var (content, fileName, contentType) = await _documents.DownloadAsync(employeeId, documentId, ct);
return File(content, contentType, fileName);
}
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetDocumentStatus(
int employeeId, int documentId, [FromBody] UpdateEmployeeDocumentStatusRequest request, CancellationToken ct)
{
await _documents.SetStatusAsync(employeeId, documentId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,74 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/employment-types")]
public sealed class EmploymentTypesController : ApiControllerBase
{
private readonly IEmploymentTypeService _employmentTypes;
public EmploymentTypesController(IEmploymentTypeService employmentTypes) => _employmentTypes = employmentTypes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<EmploymentTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<EmploymentTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _employmentTypes.ListAsync(query, status, ct));
[HttpGet("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmploymentTypeDto>> GetById(int employmentTypeId, CancellationToken ct)
{
var result = await _employmentTypes.GetAsync(employmentTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmploymentTypeDto>> Create([FromBody] CreateEmploymentTypeRequest request, CancellationToken ct)
{
var result = await _employmentTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
}
[HttpPut("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<EmploymentTypeDto>> Update(int employmentTypeId, [FromBody] UpdateEmploymentTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _employmentTypes.UpdateAsync(employmentTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{employmentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int employmentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{employmentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int employmentTypeId, CancellationToken ct)
{
await _employmentTypes.DeleteAsync(employmentTypeId, ct);
return NoContent();
}
}
@@ -0,0 +1,74 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/hr-document-types")]
public sealed class HrDocumentTypesController : ApiControllerBase
{
private readonly IHrDocumentTypeService _types;
public HrDocumentTypesController(IHrDocumentTypeService types) => _types = types;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<HrDocumentTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<HrDocumentTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _types.ListAsync(query, status, ct));
[HttpGet("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<HrDocumentTypeDto>> GetById(int hrDocumentTypeId, CancellationToken ct)
{
var result = await _types.GetAsync(hrDocumentTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<HrDocumentTypeDto>> Create([FromBody] CreateHrDocumentTypeRequest request, CancellationToken ct)
{
var result = await _types.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
}
[HttpPut("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<HrDocumentTypeDto>> Update(int hrDocumentTypeId, [FromBody] UpdateHrDocumentTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _types.UpdateAsync(hrDocumentTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{hrDocumentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int hrDocumentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{hrDocumentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int hrDocumentTypeId, CancellationToken ct)
{
await _types.DeleteAsync(hrDocumentTypeId, ct);
return NoContent();
}
}
@@ -0,0 +1,52 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
[Route("api/v1/reports/hrm")]
public sealed class HrReportsController : ApiControllerBase
{
private readonly IHrReportService _reports;
public HrReportsController(IHrReportService reports) => _reports = reports;
[HttpGet("attendance-summary")]
[ProducesResponseType(typeof(List<AttendanceSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AttendanceSummaryRowDto>>> AttendanceSummary(
[FromQuery] int periodYear, [FromQuery] int periodMonth, [FromQuery] int? departmentId, CancellationToken ct)
=> Ok(await _reports.AttendanceSummaryAsync(periodYear, periodMonth, departmentId, ct));
[HttpGet("overtime")]
[ProducesResponseType(typeof(List<OvertimeReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<OvertimeReportRowDto>>> Overtime(
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.OvertimeReportAsync(periodYear, periodMonth, ct));
[HttpGet("late-arrivals")]
[ProducesResponseType(typeof(List<LateArrivalReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LateArrivalReportRowDto>>> LateArrivals(
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
[HttpGet("payroll-register")]
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
[HttpGet("salary-history")]
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
=> Ok(await _reports.SalaryHistoryAsync(employeeId, ct));
[HttpGet("leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LeaveBalanceReportRowDto>>> LeaveBalances([FromQuery] int year, CancellationToken ct)
=> Ok(await _reports.LeaveBalanceReportAsync(year, ct));
[HttpGet("document-expiry")]
[ProducesResponseType(typeof(List<DocumentExpiryReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<DocumentExpiryReportRowDto>>> DocumentExpiry([FromQuery] int withinDays, CancellationToken ct)
=> Ok(await _reports.DocumentExpiryReportAsync(withinDays, ct));
}
@@ -0,0 +1,71 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Route("api/v1/leave-requests")]
public sealed class LeaveRequestsController : ApiControllerBase
{
private readonly ILeaveRequestService _requests;
private readonly ICurrentUser _currentUser;
public LeaveRequestsController(ILeaveRequestService requests, ICurrentUser currentUser)
{
_requests = requests;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<LeaveRequestDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LeaveRequestDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? employeeId, [FromQuery] LeaveRequestStatus? status, CancellationToken ct)
=> Ok(await _requests.ListAsync(query, employeeId, status, ct));
[HttpGet("{leaveRequestId:int}")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LeaveRequestDto>> GetById(int leaveRequestId, CancellationToken ct)
{
var result = await _requests.GetAsync(leaveRequestId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<LeaveRequestDto>> Create([FromBody] CreateLeaveRequestRequest request, CancellationToken ct)
{
var result = await _requests.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
}
[HttpPost("{leaveRequestId:int}/submit")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
[HttpPost("{leaveRequestId:int}/approve")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
[HttpPost("{leaveRequestId:int}/reject")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
[HttpPost("{leaveRequestId:int}/cancel")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Cancel(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.CancelAsync(leaveRequestId, ct));
}
@@ -0,0 +1,74 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Route("api/v1/leave-types")]
public sealed class LeaveTypesController : ApiControllerBase
{
private readonly ILeaveTypeService _leaveTypes;
public LeaveTypesController(ILeaveTypeService leaveTypes) => _leaveTypes = leaveTypes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<LeaveTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LeaveTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _leaveTypes.ListAsync(query, status, ct));
[HttpGet("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LeaveTypeDto>> GetById(int leaveTypeId, CancellationToken ct)
{
var result = await _leaveTypes.GetAsync(leaveTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveTypeDto>> Create([FromBody] CreateLeaveTypeRequest request, CancellationToken ct)
{
var result = await _leaveTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
}
[HttpPut("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<LeaveTypeDto>> Update(int leaveTypeId, [FromBody] UpdateLeaveTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _leaveTypes.UpdateAsync(leaveTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{leaveTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int leaveTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{leaveTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int leaveTypeId, CancellationToken ct)
{
await _leaveTypes.DeleteAsync(leaveTypeId, ct);
return NoContent();
}
}
@@ -0,0 +1,85 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payroll-runs")]
public sealed class PayrollRunsController : ApiControllerBase
{
private readonly IPayrollRunService _payrollRuns;
private readonly ICurrentUser _currentUser;
public PayrollRunsController(IPayrollRunService payrollRuns, ICurrentUser currentUser)
{
_payrollRuns = payrollRuns;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PayrollRunDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PayrollRunDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? periodYear, [FromQuery] int? periodMonth,
[FromQuery] PayrollRunStatus? status, CancellationToken ct)
=> Ok(await _payrollRuns.ListAsync(query, periodYear, periodMonth, status, ct));
[HttpGet("{payrollRunId:int}")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayrollRunDto>> GetById(int payrollRunId, CancellationToken ct)
{
var result = await _payrollRuns.GetAsync(payrollRunId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{payrollRunId:int}/lines")]
[ProducesResponseType(typeof(List<PayrollLineDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollLineDto>>> ListLines(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ListLinesAsync(payrollRunId, ct));
[HttpGet("{payrollRunId:int}/lines/{lineId:int}")]
[ProducesResponseType(typeof(PayrollLineDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayrollLineDetailDto>> GetLine(int payrollRunId, int lineId, CancellationToken ct)
{
var result = await _payrollRuns.GetLineAsync(payrollRunId, lineId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PayrollRunDto>> Generate([FromBody] GeneratePayrollRunRequest request, CancellationToken ct)
{
var result = await _payrollRuns.GenerateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
}
[HttpPost("{payrollRunId:int}/approve")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/lock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/unlock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/generate-payslips")]
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<List<PayslipDto>>> GeneratePayslips(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.GeneratePayslipsAsync(payrollRunId, ct));
}
@@ -0,0 +1,33 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payroll-statutory-settings")]
public sealed class PayrollStatutorySettingsController : ApiControllerBase
{
private readonly IPayrollStatutorySettingService _settings;
private readonly ICurrentUser _currentUser;
public PayrollStatutorySettingsController(IPayrollStatutorySettingService settings, ICurrentUser currentUser)
{
_settings = settings;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<PayrollStatutorySettingDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
=> Ok(await _settings.ListAsync(ct));
[HttpPost]
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
{
var result = await _settings.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/payroll-statutory-settings/{result.PayrollStatutorySettingId}", result);
}
}
@@ -0,0 +1,32 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payslips")]
public sealed class PayslipsController : ApiControllerBase
{
private readonly IPayslipService _payslips;
public PayslipsController(IPayslipService payslips) => _payslips = payslips;
[HttpGet("{payslipId:int}")]
[ProducesResponseType(typeof(PayslipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayslipDto>> GetById(int payslipId, CancellationToken ct)
{
var result = await _payslips.GetAsync(payslipId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{payslipId:int}/view")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> View(int payslipId, CancellationToken ct)
{
var html = await _payslips.RenderHtmlAsync(payslipId, ct);
return html is null ? NotFound() : Content(html, "text/html");
}
}
@@ -0,0 +1,74 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/salary-components")]
public sealed class SalaryComponentsController : ApiControllerBase
{
private readonly ISalaryComponentService _components;
public SalaryComponentsController(ISalaryComponentService components) => _components = components;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalaryComponentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalaryComponentDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _components.ListAsync(query, status, ct));
[HttpGet("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalaryComponentDto>> GetById(int salaryComponentId, CancellationToken ct)
{
var result = await _components.GetAsync(salaryComponentId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalaryComponentDto>> Create([FromBody] CreateSalaryComponentRequest request, CancellationToken ct)
{
var result = await _components.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
}
[HttpPut("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalaryComponentDto>> Update(int salaryComponentId, [FromBody] UpdateSalaryComponentRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _components.UpdateAsync(salaryComponentId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{salaryComponentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int salaryComponentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent();
}
[HttpDelete("{salaryComponentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int salaryComponentId, CancellationToken ct)
{
await _components.DeleteAsync(salaryComponentId, ct);
return NoContent();
}
}
@@ -0,0 +1,28 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/tax-slabs")]
public sealed class TaxSlabsController : ApiControllerBase
{
private readonly ITaxSlabService _taxSlabs;
public TaxSlabsController(ITaxSlabService taxSlabs) => _taxSlabs = taxSlabs;
[HttpGet]
[ProducesResponseType(typeof(List<TaxSlabDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
=> Ok(await _taxSlabs.ListAsync(ct));
[HttpPost]
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TaxSlabDto>> Create([FromBody] CreateTaxSlabRequest request, CancellationToken ct)
{
var result = await _taxSlabs.CreateAsync(request, ct);
return Created($"/api/v1/tax-slabs/{result.TaxSlabId}", result);
}
}
@@ -0,0 +1,74 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/work-shifts")]
public sealed class WorkShiftsController : ApiControllerBase
{
private readonly IWorkShiftService _shifts;
public WorkShiftsController(IWorkShiftService shifts) => _shifts = shifts;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WorkShiftDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WorkShiftDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _shifts.ListAsync(query, status, ct));
[HttpGet("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WorkShiftDto>> GetById(int workShiftId, CancellationToken ct)
{
var result = await _shifts.GetAsync(workShiftId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WorkShiftDto>> Create([FromBody] CreateWorkShiftRequest request, CancellationToken ct)
{
var result = await _shifts.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
}
[HttpPut("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<WorkShiftDto>> Update(int workShiftId, [FromBody] UpdateWorkShiftRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _shifts.UpdateAsync(workShiftId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{workShiftId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int workShiftId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
[HttpDelete("{workShiftId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int workShiftId, CancellationToken ct)
{
await _shifts.DeleteAsync(workShiftId, ct);
return NoContent();
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.ItemTypes;
@@ -13,6 +15,7 @@ namespace ERPCore.Controllers;
/// into the client-generated SKU (docs/10 Part C.9).
/// </summary>
[Route("api/v1/item-types")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemTypesController : ApiControllerBase
{
private readonly IItemTypeService _itemTypes;
+27 -7
View File
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2).</summary>
[Route("api/v1/items")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemsController : ApiControllerBase
{
private readonly IItemService _items;
@@ -27,6 +30,30 @@ public sealed class ItemsController : ApiControllerBase
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
[HttpGet("overview")]
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
CancellationToken ct)
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, ct));
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
[HttpGet("scan")]
[ProducesResponseType(typeof(ItemScanResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemScanResultDto>> Scan([FromQuery] string code, [FromQuery] int? itemId, CancellationToken ct)
{
var result = await _items.ScanAsync(code, itemId, ct);
return result is null ? NotFound() : Ok(result);
}
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
[HttpGet("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
@@ -81,11 +108,4 @@ public sealed class ItemsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
[HttpPut("{itemId:int}/uom-conversions")]
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
/// Data only — no posting in Phase 1.
/// </summary>
[Route("api/v1/journal-entries")]
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
public sealed class JournalEntriesController : ApiControllerBase
{
private readonly IAuditService _audit;
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Notifications;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/notification-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class NotificationConfigController : ApiControllerBase
{
private readonly INotificationConfigService _config;
public NotificationConfigController(INotificationConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<NotificationConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<NotificationConfigDto>> Update([FromBody] UpdateNotificationConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -0,0 +1,32 @@
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/notifications")]
public sealed class NotificationsController : ApiControllerBase
{
private readonly INotificationService _notifications;
private readonly ICurrentUser _currentUser;
public NotificationsController(INotificationService notifications, ICurrentUser currentUser)
{
_notifications = notifications;
_currentUser = currentUser;
}
[HttpGet]
public async Task<ActionResult> Get([FromQuery] int take = 20, CancellationToken ct = default)
{
var feed = await _notifications.GetFeedAsync(_currentUser.AuditUserId, take, ct);
return Ok(feed);
}
[HttpPost("mark-all-read")]
public async Task<ActionResult> MarkAllRead(CancellationToken ct)
{
await _notifications.MarkAllReadAsync(_currentUser.AuditUserId, ct);
return NoContent();
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -16,6 +18,7 @@ namespace ERPCore.Controllers;
/// </para>
/// </summary>
[Route("api/v1/product-config")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ProductConfigController : ApiControllerBase
{
private readonly IProductConfigService _config;
@@ -0,0 +1,173 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).</summary>
[Route("api/v1/production-runs")]
[Authorize(Policy = NavPolicies.Prefix + "production")]
public sealed class ProductionRunsController : ApiControllerBase
{
private readonly IProductionRunService _runs;
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] ProductionRunStatus? status,
[FromQuery] int? templateId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
/// <summary>Produced-items report (FR-MFG-13): one row per run that has produced
/// something, plus a summary total over every filtered row.</summary>
[HttpGet("produced")]
[ProducesResponseType(typeof(ProducedListResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProducedListResponseDto>> ListProduced(
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? templateId,
[FromQuery] int? warehouseId,
[FromQuery] ProductionRunStatus? status,
CancellationToken ct)
=> Ok(await _runs.ListProducedAsync(from, to, templateId, warehouseId, status, ct));
[HttpGet("{runId:int}")]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RunGraphDto>> GetById(int runId, CancellationToken ct)
{
var result = await _runs.GetAsync(runId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
{
var result = await _runs.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
}
[HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
// --- stage actions (docs/30 §D.3) ---------------------------------------
//
// Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
// §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
// the status guards instead: a double-fire finds the stage already moved on and gets a
// 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
// deviation from §D.3's "idempotency-key honored".
[HttpPost("{runId:int}/stages/{runStageId:int}/start")]
[ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<StartStageResultDto>> Start(
int runId, int runStageId,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.StartStageAsync(runId, runStageId, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunStageDto>> Complete(
int runId, int runStageId, [FromBody] CompleteStageRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
[ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ApproveStageResultDto>> Approve(
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
[ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TransferResultDto>> Transfer(
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
[HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
[ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
[HttpPost("{runId:int}/cancel")]
[ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<CancelRunResultDto>> Cancel(
int runId, [FromBody] CancelRunRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CancelAsync(runId, request, ct));
}
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
[Route("api/v1/production-templates")]
[Authorize(Policy = NavPolicies.Prefix + "production")]
public sealed class ProductionTemplatesController : ApiControllerBase
{
private readonly IProductionTemplateService _templates;
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _templates.ListAsync(query, status, ct));
[HttpGet("{templateId:int}")]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TemplateGraphDto>> GetById(int templateId, CancellationToken ct)
{
var result = await _templates.GetAsync(templateId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TemplateGraphDto>> Create(
[FromBody] SaveTemplateRequest request, CancellationToken ct)
{
var result = await _templates.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
}
[HttpPut("{templateId:int}")]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TemplateGraphDto>> Update(
int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _templates.UpdateAsync(templateId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{templateId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
{
await _templates.SetStatusAsync(templateId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.</summary>
[Route("api/v1/purchase-invoices")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseInvoicesController : ApiControllerBase
{
private readonly IPurchaseInvoiceService _invoices;
private readonly IPurchaseInvoicePaymentService _payments;
public PurchaseInvoicesController(IPurchaseInvoiceService invoices, IPurchaseInvoicePaymentService payments)
{
_invoices = invoices;
_payments = payments;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseInvoiceSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseInvoiceSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseInvoiceStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, vendorId, ct));
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
[HttpGet("eligible-grns")]
[ProducesResponseType(typeof(IReadOnlyList<GrnEligibleForInvoiceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<GrnEligibleForInvoiceDto>>> EligibleGrns([FromQuery] int vendorId, CancellationToken ct)
=> Ok(await _invoices.ListEligibleGrnsAsync(vendorId, ct));
[HttpGet("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseInvoiceDto>> GetById(int purchaseInvoiceId, CancellationToken ct)
{
var result = await _invoices.GetAsync(purchaseInvoiceId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PurchaseInvoiceDto>> Create([FromBody] CreatePurchaseInvoiceRequest request, CancellationToken ct)
{
var result = await _invoices.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/purchase-invoices/{result.Value.PurchaseInvoiceId}", result.Value);
}
[HttpPut("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<PurchaseInvoiceDto>> Update(int purchaseInvoiceId, [FromBody] UpdatePurchaseInvoiceRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _invoices.UpdateAsync(purchaseInvoiceId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{purchaseInvoiceId:int}/post")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Post(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.PostAsync(purchaseInvoiceId, ct));
[HttpPost("{purchaseInvoiceId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Cancel(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(purchaseInvoiceId, ct));
/// <summary>Pay the vendor against this invoice's balance, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(PurchaseInvoicePaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoicePaymentDto>> Pay(int purchaseInvoiceId, [FromBody] CreatePurchaseInvoicePaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(purchaseInvoiceId, request, ct);
return Created($"/api/v1/purchase-invoices/{purchaseInvoiceId}/payments/{dto.PurchaseInvoicePaymentId}", dto);
}
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<PurchaseInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<PurchaseInvoicePaymentDto>>> ListPayments(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _payments.ListAsync(purchaseInvoiceId, ct));
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Purchase-order endpoints (docs/11 §3.3).</summary>
[Route("api/v1/purchase-orders")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseOrdersController : ApiControllerBase
{
private readonly IPurchaseOrderService _pos;
@@ -32,7 +35,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Create a PO — auto-approved on creation in Phase 1 (FR-PROC-04). Totals computed server-side.</summary>
/// <summary>Create a PO — non-draft is issued straight into PendingApproval (FR-PROC-04). Totals computed server-side.</summary>
[HttpPost]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -57,7 +60,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
/// <summary>Submit a Draft PO (Draft → PendingApproval). 409 PO_NOT_EDITABLE if not Draft.</summary>
[HttpPost("{poId:int}/submit")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -67,6 +70,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
[HttpDelete("{poId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -76,13 +80,28 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return NoContent();
}
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
/// <summary>Approve — PendingApproval → Approved. 403 PO_APPROVAL_FORBIDDEN if the caller
/// isn't an Admin or is the PO's own creator; 409 PO_NOT_PENDING_APPROVAL otherwise.</summary>
[HttpPost("{poId:int}/approve")]
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Approve(int poId, CancellationToken ct)
=> Ok(await _pos.ApproveAsync(poId, ct));
/// <summary>Reject — PendingApproval → Draft, so the requester can revise and resubmit.
/// Same caller restrictions as <see cref="Approve"/>.</summary>
[HttpPost("{poId:int}/reject")]
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Reject(int poId, [FromBody] RejectPurchaseOrderRequest request, CancellationToken ct)
=> Ok(await _pos.RejectAsync(poId, request.Reason, ct));
/// <summary>Cancel — 409 if any goods have been received against the PO.</summary>
[HttpPost("{poId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
[Route("api/v1/purchase-returns")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseReturnsController : ApiControllerBase
{
private readonly IPurchaseReturnService _returns;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Purchase-requisition endpoints (docs/11 §3.1).</summary>
[Route("api/v1/requisitions")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class RequisitionsController : ApiControllerBase
{
private readonly IRequisitionService _requisitions;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>RFQ &amp; quotation endpoints (docs/11 §3.2).</summary>
[Route("api/v1/rfqs")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class RfqsController : ApiControllerBase
{
private readonly IRfqService _rfqs;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
[Route("api/v1/roles")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class RolesController : ApiControllerBase
{
private readonly IRoleService _roles;
@@ -64,6 +67,7 @@ public sealed class RolesController : ApiControllerBase
}
[HttpDelete("{roleId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "settings" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Cashier day-end close-out — locks a cashier's Posted sales slips for a business
/// date and posts one consolidated GL journal entry for the day (docs/14 Sales API).</summary>
[Route("api/v1/sales-day-end")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesDayEndController : ApiControllerBase
{
private readonly ISalesDayEndService _dayEnds;
public SalesDayEndController(ISalesDayEndService dayEnds) => _dayEnds = dayEnds;
/// <summary>List day-end closes, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesDayEndSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesDayEndSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? cashierUserId, CancellationToken ct)
=> Ok(await _dayEnds.ListAsync(query, cashierUserId, ct));
/// <summary>What closing this cashier/date right now would include (or the existing close's totals, if already closed).</summary>
[HttpGet("preview")]
[ProducesResponseType(typeof(SalesDayEndPreviewDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesDayEndPreviewDto>> Preview(
[FromQuery] int cashierUserId, [FromQuery] DateOnly? businessDate, CancellationToken ct)
=> Ok(await _dayEnds.PreviewAsync(cashierUserId, businessDate ?? DateOnly.FromDateTime(DateTime.UtcNow), ct));
[HttpGet("{salesDayEndId:int}")]
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesDayEndDto>> GetById(int salesDayEndId, CancellationToken ct)
{
var dto = await _dayEnds.GetAsync(salesDayEndId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Close the day: lock every Posted slip for (CashierUserId, BusinessDate) and post the
/// consolidated GL journal entry. Idempotent — closing an already-closed date replays it.</summary>
[HttpPost]
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalesDayEndDto>> Close([FromBody] CreateSalesDayEndRequest request, CancellationToken ct)
{
var dto = await _dayEnds.CloseAsync(request, ct);
return Created($"/api/v1/sales-day-end/{dto.SalesDayEndId}", dto);
}
}
@@ -0,0 +1,101 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-invoices")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
private readonly ISalesInvoicePaymentService _payments;
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
{
_invoices = invoices;
_payments = payments;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesInvoiceSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesInvoiceDto>> GetById(int salesInvoiceId, CancellationToken ct)
{
var result = await _invoices.GetAsync(salesInvoiceId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{salesInvoiceId:int}/posting-check")]
[ProducesResponseType(typeof(SalesInvoicePostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesInvoicePostingCheckDto>> PostingCheck(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct));
[HttpPost]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
{
var result = await _invoices.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/sales-invoices/{result.Value.SalesInvoiceId}", result.Value);
}
[HttpPut("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesInvoiceDto>> Update(int salesInvoiceId, [FromBody] UpdateSalesInvoiceRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _invoices.UpdateAsync(salesInvoiceId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{salesInvoiceId:int}/post")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceDto>> Post(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.PostAsync(salesInvoiceId, ct));
[HttpPost("{salesInvoiceId:int}/cancel")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
/// <summary>Pay the customer's balance against this invoice, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(SalesInvoicePaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalesInvoicePaymentDto>> Pay(int salesInvoiceId, [FromBody] CreateSalesInvoicePaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(salesInvoiceId, request, ct);
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
}
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> ListPayments(int salesInvoiceId, CancellationToken ct)
=> Ok(await _payments.ListAsync(salesInvoiceId, ct));
}
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/reports/sales")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesReportsController : ApiControllerBase
{
private readonly ISalesReportService _reports;
public SalesReportsController(ISalesReportService reports) => _reports = reports;
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
=> Ok(_reports.ListReports());
[HttpGet("{reportId}")]
[ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<SalesReportDefinitionDto> GetReport(string reportId)
{
var report = _reports.GetReport(reportId);
return report is null ? NotFound() : Ok(report);
}
[HttpPost("query")]
[ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesReportQueryResponse>> Query([FromBody] SalesReportQueryRequest request, CancellationToken ct)
{
var rows = await _reports.QueryAsync(request.ReportType, request.From, request.To, request.ItemId, request.CustomerId, request.WarehouseId, ct);
return Ok(new SalesReportQueryResponse(request.ReportType, request.From, request.To, rows));
}
}
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
[Route("api/v1/sales-returns")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesReturnsController : ApiControllerBase
{
private readonly ISalesReturnService _returns;
public SalesReturnsController(ISalesReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? customerId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, customerId, warehouseId, ct));
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
[HttpGet("remaining")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoiceLineRemainingDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesInvoiceLineRemainingDto>>> GetRemaining([FromQuery] int salesInvoiceId, CancellationToken ct)
=> Ok(await _returns.GetRemainingByInvoiceAsync(salesInvoiceId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (inbound movement).</summary>
[HttpPost]
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<SalesReturnDto>> Create([FromBody] CreateSalesReturnRequest request, CancellationToken ct)
{
var dto = await _returns.CreateAsync(request, ct);
return Created($"/api/v1/sales-returns/{dto.ReturnId}", dto);
}
}
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesSlipPromotionsController : ApiControllerBase
{
private readonly ISalesPromotionSuggestionService _suggestions;
public SalesSlipPromotionsController(ISalesPromotionSuggestionService suggestions) => _suggestions = suggestions;
[HttpGet]
[ProducesResponseType(typeof(SalesFreeIssueSuggestionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesFreeIssueSuggestionDto>> Get(int salesSlipId, CancellationToken ct)
{
var result = await _suggestions.GetFreeIssueSuggestionsAsync(salesSlipId, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesSlipsController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
public SalesSlipsController(ISalesSlipService slips) => _slips = slips;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesSlipSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesSlipSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesSlipStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _slips.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{salesSlipId:int}")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipDto>> GetById(int salesSlipId, CancellationToken ct)
{
var result = await _slips.GetAsync(salesSlipId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{salesSlipId:int}/posting-check")]
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.CheckPostingAsync(salesSlipId, ct));
[HttpPost]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
{
var result = await _slips.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/sales-slips/{result.Value.SalesSlipId}", result.Value);
}
[HttpPut("{salesSlipId:int}")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesSlipDto>> Update(int salesSlipId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _slips.UpdateAsync(salesSlipId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{salesSlipId:int}/post")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesSlipDto>> Post(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.PostAsync(salesSlipId, ct));
[HttpPost("{salesSlipId:int}/cancel")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesSlipDto>> Cancel(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.CancelAsync(salesSlipId, ct));
}
@@ -0,0 +1,20 @@
using ERPCore.Dtos.Search;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/search")]
public sealed class SearchController : ApiControllerBase
{
private readonly IGlobalSearchService _search;
public SearchController(IGlobalSearchService search) => _search = search;
[HttpGet]
public async Task<ActionResult<IReadOnlyList<GlobalSearchResultDto>>> Get([FromQuery] string q, CancellationToken ct)
{
var results = await _search.SearchAsync(q, 6, ct);
return Ok(results);
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
[Route("api/v1/stock-adjustments")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockAdjustmentsController : ApiControllerBase
{
private readonly IAdjustmentService _adjustments;
@@ -17,8 +20,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Stock;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
[Route("api/v1/stock")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockController : ApiControllerBase
{
private readonly IStockService _stock;
@@ -28,8 +31,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, branchId, query, ct));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
@@ -39,11 +42,11 @@ public sealed class StockController : ApiControllerBase
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -54,8 +57,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("reorder-alerts")]
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
[FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
[FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _reorder.GetAlertsAsync(warehouseId, branchId, query, ct));
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
[Route("api/v1/stock-counts")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockCountsController : ApiControllerBase
{
private readonly ICountService _counts;
@@ -18,8 +21,8 @@ public sealed class StockCountsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, branchId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
[Route("api/v1/stock-transfers")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockTransfersController : ApiControllerBase
{
private readonly ITransferService _transfers;
@@ -19,8 +22,8 @@ public sealed class StockTransfersController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, branchId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Categories;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
/// since a subcategory only exists in the context of one.
/// </summary>
[Route("api/v1/subcategories")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class SubCategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
@@ -0,0 +1,70 @@
using ERPCore.Dtos.Support;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/support-tickets")]
public sealed class SupportTicketsController : ApiControllerBase
{
private readonly ISupportTicketService _tickets;
private readonly ICurrentUser _currentUser;
public SupportTicketsController(ISupportTicketService tickets, ICurrentUser currentUser)
{
_tickets = tickets;
_currentUser = currentUser;
}
[HttpPost]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SupportTicketDto>> Create([FromBody] CreateSupportTicketRequest request, CancellationToken ct)
{
var result = await _tickets.CreateAsync(_currentUser.AuditUserId, request, ct);
SetETag(result.RowVersion);
return CreatedAtAction(nameof(GetById), new { id = result.Value.TicketId }, result.Value);
}
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<SupportTicketSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SupportTicketSummaryDto>>> GetList(CancellationToken ct)
{
var list = await _tickets.GetListAsync(_currentUser.AuditUserId, _currentUser.IsAdmin, ct);
return Ok(list);
}
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SupportTicketDto>> GetById(int id, CancellationToken ct)
{
var result = await _tickets.GetByIdAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{id:int}/messages")]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SupportTicketDto>> AddMessage(int id, [FromBody] AddSupportTicketMessageRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _tickets.AddMessageAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut("{id:int}/status")]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SupportTicketDto>> UpdateStatus(int id, [FromBody] UpdateSupportTicketStatusRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _tickets.UpdateStatusAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.TaxRates;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Tax rate master endpoints — see <see cref="Domain.Entities.TaxRate"/>.</summary>
[Route("api/v1/tax-rates")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class TaxRatesController : ApiControllerBase
{
private readonly ITaxRateService _taxRates;
public TaxRatesController(ITaxRateService taxRates) => _taxRates = taxRates;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TaxRateDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TaxRateDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _taxRates.ListAsync(query, status, ct));
[HttpGet("{taxRateId:int}")]
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TaxRateDto>> GetById(int taxRateId, CancellationToken ct)
{
var result = await _taxRates.GetAsync(taxRateId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<TaxRateDto>> Create([FromBody] CreateTaxRateRequest request, CancellationToken ct)
{
var result = await _taxRates.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/tax-rates/{result.Value.TaxRateId}", result.Value);
}
[HttpPut("{taxRateId:int}")]
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<TaxRateDto>> Update(int taxRateId, [FromBody] UpdateTaxRateRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _taxRates.UpdateAsync(taxRateId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{taxRateId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int taxRateId, [FromBody] UpdateTaxRateStatusRequest request, CancellationToken ct)
{
await _taxRates.SetStatusAsync(taxRateId, request.Status, ct);
return NoContent();
}
}
+17 -2
View File
@@ -1,3 +1,6 @@
using ERPCore.Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
@@ -7,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).</summary>
[Route("api/v1/uoms")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class UomsController : ApiControllerBase
{
private readonly IUomService _uoms;
@@ -15,8 +19,9 @@ public sealed class UomsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<UomDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, status, ct));
[HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
@@ -26,4 +31,14 @@ public sealed class UomsController : ApiControllerBase
var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{uomId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
{
await _uoms.SetStatusAsync(uomId, request.Status, ct);
return NoContent();
}
}
+57 -4
View File
@@ -1,6 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
@@ -10,16 +17,32 @@ namespace ERPCore.Controllers;
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
/// </summary>
[Route("api/v1/users")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
private readonly IAuditService _audit;
private readonly ICurrentUser _currentUser;
public UsersController(IUserManagementService users) => _users = users;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
{
_users = users;
_links = links;
_audit = audit;
_currentUser = currentUser;
}
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(EmployeeMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<EmployeeMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new EmployeeMatchResponse(await _links.FindStaffCandidateByEmailAsync(email, ct)));
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _users.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
@@ -41,7 +64,7 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
var result = await _users.CreateAsync(request, ct);
var result = await _users.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/users/{result.UserId}", result);
}
@@ -50,4 +73,34 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
[HttpPatch("{userId:int}/status")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")]
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
/// <summary>
/// Admin-only escape hatch: permanently deletes a stale AuthHex identity (by email/mobile/NIC/
/// username) that is blocking new registrations. Does not touch ERPCore's own `users` table.
/// See <see cref="IUserManagementService.PurgeAuthHexIdentityAsync"/>.
/// </summary>
[HttpPost("purge-authhex-identity")]
[ProducesResponseType(typeof(DeleteAuthHexUserResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<DeleteAuthHexUserResult>> PurgeAuthHexIdentity([FromBody] PurgeAuthHexIdentityRequest request, CancellationToken ct)
{
if (!_currentUser.IsAdmin)
throw new DomainException("FORBIDDEN", "Only an administrator can purge an AuthHex identity.", 403);
return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
[Route("api/v1/vendors")]
[Authorize(Policy = NavPolicies.Prefix + "vendors")]
public sealed class VendorsController : ApiControllerBase
{
private readonly IVendorService _vendors;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Warehouses;
using ERPCore.Services.Interfaces;
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
/// <summary>Warehouse &amp; bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).</summary>
[Route("api/v1/warehouses")]
[Authorize(Policy = NavPolicies.Prefix + "warehouses")]
public sealed class WarehousesController : ApiControllerBase
{
private readonly IWarehouseService _warehouses;
@@ -15,8 +18,9 @@ public sealed class WarehousesController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, branchId, ct));
[HttpGet("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
@@ -36,6 +40,24 @@ public sealed class WarehousesController : ApiControllerBase
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
}
[HttpPut("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WarehouseDto>> Update(int warehouseId, [FromBody] UpdateWarehouseRequest request, CancellationToken ct)
=> Ok(await _warehouses.UpdateAsync(warehouseId, request, ct));
[HttpDelete("{warehouseId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "warehouses" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int warehouseId, CancellationToken ct)
{
await _warehouses.DeleteAsync(warehouseId, ct);
return NoContent();
}
[HttpGet("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
+12
View File
@@ -10,8 +10,20 @@ public static class DocumentTypes
public const string Rfq = "RFQ";
public const string PurchaseOrder = "PO";
public const string Grn = "GRN";
public const string PurchaseInvoice = "PINV";
public const string Transfer = "TRF";
public const string Adjustment = "ADJ";
public const string Count = "CNT";
public const string PurchaseReturn = "PRET";
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
public const string Production = "PRD";
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
public const string BundleSaleTemplate = "TPL";
public const string SalesReturn = "SRET";
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
public const string SalesDayEnd = "DEND";
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per employee/day attendance row (FR-HR-ATT). <see cref="WorkShiftId"/> is
/// snapshotted from the employee's shift at ingestion time (docs/12-BACKEND-HRM.md
/// A.3) so a later shift reassignment never retroactively changes historical
/// Late/OT figures. Model: docs/12-BACKEND-HRM.md Part C.4.
/// </summary>
public class AttendanceRecord
{
public int AttendanceRecordId { get; set; }
public int? AttendanceUploadBatchId { get; set; }
public AttendanceUploadBatch? AttendanceUploadBatch { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public DateTime AttendanceDate { get; set; }
public TimeSpan? CheckIn { get; set; }
public TimeSpan? CheckOut { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
public int WorkingMinutes { get; set; }
public int LateMinutes { get; set; }
public int EarlyLeaveMinutes { get; set; }
public int OvertimeMinutes { get; set; }
public AttendanceStatus AttendanceStatus { get; set; }
public RowValidationStatus RowValidationStatus { get; set; } = RowValidationStatus.Valid;
public int? DuplicateOfAttendanceRecordId { get; set; }
public string? Notes { get; set; }
public bool IsManualOverride { get; set; }
public int? EditedBy { get; set; }
public DateTime? EditedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Attendance upload batch (FR-HR-ATT) — the transactional document driving the
/// exact status flow Draft→Validated→Confirmed→UsedInPayroll. Scoped to exactly
/// one payroll period, numbered via <see cref="NumberSequence"/> (docType "ATT").
/// Model: docs/12-BACKEND-HRM.md Part C.4.
/// </summary>
public class AttendanceUploadBatch
{
public int AttendanceUploadBatchId { get; set; }
public string DocNo { get; set; } = string.Empty;
public DateTime PeriodStart { get; set; }
public DateTime PeriodEnd { get; set; }
public AttendanceSourceType SourceType { get; set; }
public string? OriginalFileName { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
public AttendanceBatchStatus Status { get; set; } = AttendanceBatchStatus.Draft;
public int? ConfirmedBy { get; set; }
public DateTime? ConfirmedAt { get; set; }
public int RowCountTotal { get; set; }
public int RowCountDuplicate { get; set; }
public int RowCountError { get; set; }
public uint RowVersion { get; set; }
}
+3
View File
@@ -13,4 +13,7 @@ public class Batch
public string BatchNo { get; set; } = string.Empty;
public DateOnly? ExpiryDate { get; set; }
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
public string? Barcode { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Required by
/// <see cref="Employee.BranchId"/>, optionally referenced by <see cref="PayrollRun.BranchId"/>.
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Branch
{
public int BranchId { get; set; }
/// <summary>Server-generated on create ("BR-001", "BR-002", ...) — never accepted from the client.</summary>
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Address { get; set; }
public string? PhoneNumber { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,33 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch configuration — a singleton row (single-tenant, docs/00-CORE §1) gating whether
/// this deployment uses branches at all. When <see cref="BranchingEnabled"/> is off, the
/// Employee/Warehouse create forms stop asking for a branch and every new record is
/// silently assigned to the deployment's one default branch instead — schema-wise
/// <c>Employee.BranchId</c>/<c>Warehouse.BranchId</c> stay <c>NOT NULL</c> either way, only
/// the UI/UX requirement toggles. See <see cref="Services.BranchConfigService.ResolveBranchIdAsync"/>.
/// </summary>
public class BranchConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public bool BranchingEnabled { get; set; } = true;
/// <summary>Independent sub-toggle: when the global <see cref="BranchingEnabled"/> is on
/// but this is off, Warehouses stop asking for/showing a branch while Employees etc.
/// still do. Meaningless when <see cref="BranchingEnabled"/> is off (everything is
/// single-branch already).</summary>
public bool WarehouseBranchingEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,39 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class BundleSale
{
public int BundleSaleId { get; set; }
public string BundleNo { get; set; } = string.Empty;
public DateTime BundleDate { get; set; }
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public string CustomerSnapshotName { get; set; } = string.Empty;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int CashierUserId { get; set; }
public User? CashierUser { get; set; }
public int BundleSaleTemplateId { get; set; }
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
public string BundleName { get; set; } = string.Empty;
public string BundleCode { get; set; } = string.Empty;
public BundleSaleStatus Status { get; set; } = BundleSaleStatus.Draft;
public decimal ComponentSubtotal { get; set; }
public decimal BundlePrice { get; set; }
public decimal MarginAmount { get; set; }
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
/// <summary>Set once this bundle's business date is closed by <see cref="SalesDayEnd"/> — a
/// closed bundle sale is frozen (no further edits). Same role as <see cref="SalesSlip.DayEndId"/>;
/// a bundle sale is a cashier document exactly like a sales slip, just priced as a set.</summary>
public int? DayEndId { get; set; }
public SalesDayEnd? DayEnd { get; set; }
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
}
@@ -0,0 +1,22 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class BundleSaleLine
{
public int BundleSaleLineId { get; set; }
public int BundleSaleId { get; set; }
public BundleSale? BundleSale { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Qty { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal { get; set; }
public bool IncludeInBundle { get; set; } = true;
public bool IsComponent { get; set; } = true;
public int? ParentLineId { get; set; }
}
@@ -0,0 +1,17 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class BundleSaleTemplate
{
public int BundleSaleTemplateId { get; set; }
public string TemplateCode { get; set; } = string.Empty;
public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
public ICollection<BundleSaleTemplateLine> Lines { get; set; } = new List<BundleSaleTemplateLine>();
}
@@ -0,0 +1,18 @@
namespace ERPCore.Domain.Entities;
public class BundleSaleTemplateLine
{
public int BundleSaleTemplateLineId { get; set; }
public int BundleSaleTemplateId { get; set; }
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }
public bool IncludeInBundle { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,41 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Customer master for both B2B and B2C sales.
/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse.
/// </summary>
public class Customer
{
public int CustomerId { get; set; }
public string CustomerCode { get; set; } = string.Empty;
public CustomerType CustomerType { get; set; } = CustomerType.B2C;
public string Name { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? Country { get; set; }
public string? TaxRegistrationNo { get; set; }
public decimal CreditLimit { get; set; }
public int CreditDays { get; set; }
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Department master (FR-HR-MD-01) — unlimited self-nesting for a real org chart
/// (unlike the two-level-capped <see cref="Category"/>); cycle prevention is a
/// service-level check on write, not a DB constraint. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Department
{
public int DepartmentId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int? ParentDepartmentId { get; set; }
public Department? ParentDepartment { get; set; }
public int? HeadEmployeeId { get; set; }
public Employee? HeadEmployee { get; set; }
public int? BranchId { get; set; }
public Branch? Branch { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,19 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Job title master (FR-HR-MD-01), standalone — not FK'd to Department, since a
/// title like "Accountant" can exist in multiple departments. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Designation
{
public int DesignationId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,78 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
/// account): not every employee has a login, and not every login belongs to an
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Normal separation is
/// recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/> rather than deletion;
/// an explicit hard delete is also available (<see cref="Services.Interfaces.IEmployeeService.DeleteAsync"/>)
/// and cascades to every dependent HR record, blocked only if payroll history exists.
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
/// year-scoping is the wrong shape for an identifier that must never look "reset".
/// </summary>
public class Employee
{
public int EmployeeId { get; set; }
public string EmployeeCode { get; set; } = string.Empty;
// Identity
public string FullName { get; set; } = string.Empty;
public string? Nic { get; set; }
public DateTime? DateOfBirth { get; set; }
public Gender? Gender { get; set; }
public string? Nationality { get; set; }
public string? ProfilePhotoPath { get; set; }
// Contact
/// <summary>The field used for the bidirectional Employee&lt;-&gt;User email cross-check.</summary>
public string? Email { get; set; }
public string? PersonalMobile { get; set; }
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? PostalCode { get; set; }
public string? Country { get; set; }
// Emergency contact
public string? EmergencyContactName { get; set; }
public string? EmergencyContactRelationship { get; set; }
public string? EmergencyContactPhone { get; set; }
// Employment
public DateTime HireDate { get; set; }
public DateTime? ConfirmationDate { get; set; }
public DateTime? LastWorkingDate { get; set; }
public int DepartmentId { get; set; }
public Department? Department { get; set; }
public int DesignationId { get; set; }
public Designation? Designation { get; set; }
public int EmploymentTypeId { get; set; }
public EmploymentType? EmploymentType { get; set; }
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
public int? ReportingManagerId { get; set; }
public Employee? ReportingManager { get; set; }
// Statutory (Sri Lanka)
public string? EpfNumber { get; set; }
public string? EtfNumber { get; set; }
public string? TaxIdentificationNumber { get; set; }
/// <summary>Optional login account link (unique — one User backs at most one Employee).</summary>
public int? UserId { get; set; }
public User? User { get; set; }
public EmployeeStatus Status { get; set; } = EmployeeStatus.Active;
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public int? UpdatedBy { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,28 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Employee bank account (FR-HR-MD-03), one-to-many — a future split-payment
/// improvement is possible since this isn't a 1:1 scalar set. Exactly one row per
/// employee is <see cref="IsPrimary"/>; payroll disbursement targets it.
/// Model: docs/12-BACKEND-HRM.md Part C.2.
/// </summary>
public class EmployeeBankDetail
{
public int EmployeeBankDetailId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public string BankName { get; set; } = string.Empty;
public string BranchName { get; set; } = string.Empty;
public string AccountNumber { get; set; } = string.Empty;
public string AccountHolderName { get; set; } = string.Empty;
public string? SwiftCode { get; set; }
public bool IsPrimary { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,36 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Uploaded staff document (FR-HR-DOC-02..04) — the user's "Doc". <see cref="StoredFileName"/>/
/// <see cref="RelativePath"/> are server-generated (never the client's filename), so the
/// file is only ever reachable through <see cref="Services.Interfaces.IFileStorageService"/>,
/// never a guessable static path. Archived, not deleted, so the audit trail of what was
/// once on file is retained. Model: docs/12-BACKEND-HRM.md Part C.3.
/// </summary>
public class EmployeeDocument
{
public int EmployeeDocumentId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int HrDocumentTypeId { get; set; }
public HrDocumentType? HrDocumentType { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
public string StoredFileName { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public DateTime? IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; }
public string? Notes { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
public int? VerifiedBy { get; set; }
public DateTime? VerifiedAt { get; set; }
public EmployeeDocumentStatus Status { get; set; } = EmployeeDocumentStatus.Active;
public uint RowVersion { get; set; }
}
@@ -0,0 +1,35 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Loan/Advance (FR-HR-PAY-03) — <see cref="LoanKind"/> discriminates, structurally
/// identical otherwise. <see cref="OutstandingBalance"/> is denormalized (parallel to
/// <c>StockLayer.QtyRemaining</c>). Numbered via <see cref="NumberSequence"/> (docType "LOAN").
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class EmployeeLoan
{
public int EmployeeLoanId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public LoanKind LoanKind { get; set; }
public decimal PrincipalAmount { get; set; }
public decimal InterestRate { get; set; }
public decimal InstallmentAmount { get; set; }
public int NumberOfInstallments { get; set; }
public int StartYear { get; set; }
public int StartMonth { get; set; }
public decimal OutstandingBalance { get; set; }
public LoanStatus Status { get; set; } = LoanStatus.Active;
public int ApprovedBy { get; set; }
public DateTime ApprovedAt { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public List<LoanInstallment> Installments { get; set; } = new();
}
@@ -0,0 +1,30 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Effective-dated salary structure header (FR-HR-PAY-02) — the audit trail a
/// salary revision needs (docs/12-BACKEND-HRM.md §13): exactly one row with
/// <see cref="EffectiveTo"/> null (the current one) per employee at a time.
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class EmployeeSalaryStructure
{
public int EmployeeSalaryStructureId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public DateTime EffectiveFrom { get; set; }
public DateTime? EffectiveTo { get; set; }
public decimal BasicSalary { get; set; }
public string Currency { get; set; } = "LKR";
public SalaryStructureStatus Status { get; set; } = SalaryStructureStatus.Active;
public int ApprovedBy { get; set; }
public DateTime ApprovedAt { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public List<EmployeeSalaryStructureLine> Lines { get; set; } = new();
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Entities;
/// <summary>Allowance/other-deduction line on a salary structure. Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
public class EmployeeSalaryStructureLine
{
public int EmployeeSalaryStructureLineId { get; set; }
public int EmployeeSalaryStructureId { get; set; }
public EmployeeSalaryStructure? EmployeeSalaryStructure { get; set; }
public int SalaryComponentId { get; set; }
public SalaryComponent? SalaryComponent { get; set; }
public decimal Amount { get; set; }
}
@@ -0,0 +1,20 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Labor category master (FR-HR-MD-01) — a master, not an enum, mirroring
/// <see cref="Brand"/>: employment categories change with company/labor-law
/// policy without wanting a code deploy. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class EmploymentType
{
public int EmploymentTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
+15
View File
@@ -22,6 +22,11 @@ public class Grn
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public GrnStatus Status { get; set; } = GrnStatus.Draft;
public int CreatedBy { get; set; }
@@ -30,6 +35,16 @@ public class Grn
public DateTime CreatedAt { get; set; }
public DateTime? PostedAt { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm).</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
/// <summary>The Purchase Invoice this GRN has been consolidated into, if any (null until
/// invoiced). Vendor settlement is tracked on the invoice, not per-GRN — see
/// <see cref="PurchaseInvoice"/>.</summary>
public int? PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
public uint RowVersion { get; set; }
+7 -3
View File
@@ -25,9 +25,6 @@ public class GrnLine
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int? BinId { get; set; }
public Bin? Bin { get; set; }
@@ -61,4 +58,11 @@ public class GrnLine
public decimal LineTotal { get; set; }
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
/// <summary>
/// One row per received unit when <see cref="Item.Warranty"/> is
/// <see cref="Enums.Warranty.Warranty"/> — count must equal <see cref="Qty"/>.
/// Empty for a non-warranty item.
/// </summary>
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One warranty number captured against a single received unit of a warranty-tracked
/// item (<see cref="Item.Warranty"/> = <see cref="Enums.Warranty.Warranty"/>). A GRN line
/// for such an item must carry exactly <see cref="GrnLine.Qty"/> of these — one per unit —
/// mirroring how a Serial-tracked item requires one serial per unit (docs/10 Part C.3).
/// </summary>
public class GrnLineWarrantyNumber
{
public int GrnLineWarrantyNumberId { get; set; }
public int GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public string WarrantyNo { get; set; } = string.Empty;
/// <summary>Warranty coverage length in months, selected at receipt (e.g. 3/6/12/18).</summary>
public int WarrantyPeriodMonths { get; set; }
}
@@ -0,0 +1,24 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Staff document catalog (FR-HR-DOC-01) — the user's "DocType": a category of
/// document (NIC, contract, certificate...), not the uploaded file itself (see
/// <see cref="EmployeeDocument"/>, the "Doc"). Deactivated, not deleted, when
/// referenced. Model: docs/12-BACKEND-HRM.md Part C.3.
/// </summary>
public class HrDocumentType
{
public int HrDocumentTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public HrDocumentCategory Category { get; set; }
public bool RequiredAtOnboarding { get; set; }
public bool ExpiryTracked { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
+57 -2
View File
@@ -14,6 +14,9 @@ public class Item
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
public string? Barcode { get; set; }
public int CategoryId { get; set; }
public Category? Category { get; set; }
@@ -24,6 +27,12 @@ public class Item
public int? BrandId { get; set; }
public Brand? Brand { get; set; }
/// <summary>
/// The stocking unit — the pack the item is counted in (BOTTLE, PACKET, BOX, PCS).
/// <b>Every</b> quantity in the system is a count of these: stock layers, the ledger,
/// and every document line. Nothing converts, so this is the sole meaning of a
/// quantity and cannot be changed once the item has stock history.
/// </summary>
public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
@@ -32,7 +41,54 @@ public class Item
public StockNature StockNature { get; set; }
public TrackingMode TrackingMode { get; set; }
public string? TaxClass { get; set; }
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// <summary>Coverage length in months (see <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; null otherwise.</summary>
public int? WarrantyPeriodMonths { get; set; }
/// <summary>
/// The tax rate a sales/purchase line for this item defaults to (see
/// <see cref="TaxRate"/>) — replaces the old free-text "tax class" label that nothing
/// ever read. Null means no default; a line must be given an explicit tax percentage.
/// </summary>
public int? TaxRateId { get; set; }
public TaxRate? TaxRate { get; set; }
/// <summary>
/// Optional selling price range and flat price used by Sales only.
/// <c>null</c> values mean the field is not set. These values never enter costing/GRN/FIFO.
/// </summary>
public decimal? SellingMinPrice { get; set; }
public decimal? SellingMaxPrice { get; set; }
public decimal? SellingFlatPrice { get; set; }
/// <summary>
/// How much one pack holds, as the user entered it — <c>500</c> with
/// <see cref="ContentUnit"/> <c>Ml</c> for a 500 ml bottle, <c>1.5</c> with <c>L</c>
/// for a 1.5 L one. Null (together with the other three) when the item has no
/// measurable content: a screw, a label, a service.
/// <para>
/// Content never affects stock — that is always a pack count. It exists so production
/// can express a formula in millilitres or grams and resolve it to packs
/// (see <c>IItemMeasure</c>).
/// </para>
/// <para>
/// A loose bulk item bought by weight is modelled the same way:
/// <c>BaseUom = KG, ContentQty = 1, ContentUnit = Kg</c> ⇒ 1000 g per stocked unit.
/// </para>
/// </summary>
public decimal? ContentQty { get; set; }
public MeasureUnit? ContentUnit { get; set; }
/// <summary>
/// <see cref="ContentQty"/>/<see cref="ContentUnit"/> normalised to a base unit
/// (L→Ml, Kg→G, both ×1000) at write time by <c>ItemContent.Normalize</c>. Server-derived
/// and never accepted from a client. <see cref="ContentBaseUnit"/> is therefore only ever
/// <see cref="MeasureUnit.Ml"/> or <see cref="MeasureUnit.G"/>.
/// <para>Stored rather than recomputed so every consumer reads one settled number.</para>
/// </summary>
public decimal? ContentBaseQty { get; set; }
public MeasureUnit? ContentBaseUnit { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -42,5 +98,4 @@ public class Item
public uint RowVersion { get; set; }
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
}
+23 -2
View File
@@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities;
/// Material.
/// <para>
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
/// nothing: there is no value table and no join to <see cref="Item"/>. The chosen
/// values (Red, S, M) are encoded by the client into the generated SKU
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
/// </para>
/// <para>
/// It does, however, carry one piece of meaning the client acts on:
/// <see cref="IsMeasurable"/>. So this is no longer purely a dropdown source.
/// </para>
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
/// which is what the old <c>ItemType</c> enum became.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
@@ -21,6 +24,24 @@ public class ItemType
{
public int ItemTypeId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// When true, this dimension's values are content <b>measurements</b> (500 ml, 1 L) rather
/// than plain labels (Red, S). The item builder then captures a number + unit per value and
/// stamps that pair onto each generated item's <see cref="Item.ContentQty"/> /
/// <see cref="Item.ContentUnit"/>, instead of copying one form-level pair into every variant
/// — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" three correctly sized items.
/// <para>
/// This is what lets an apparel <c>Size</c> (S/M/L) stay plain text while a
/// <c>Pack Size</c>/<c>Volume</c> dimension carries ml/g/L/kg.
/// </para>
/// <para>
/// A client hint only: the server never reads it when writing an item. Each item's pair is
/// still validated and normalised on its own by <c>ItemContent</c>.
/// </para>
/// </summary>
public bool IsMeasurable { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -0,0 +1,24 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per employee/type/year leave entitlement (FR-HR-LV-03). Unique on
/// (EmployeeId, LeaveTypeId, Year). RemainingDays is a computed projection, not
/// stored. Model: docs/12-BACKEND-HRM.md Part C.5.
/// </summary>
public class LeaveBalance
{
public int LeaveBalanceId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int LeaveTypeId { get; set; }
public LeaveType? LeaveType { get; set; }
public int Year { get; set; }
public decimal EntitledDays { get; set; }
public decimal TakenDays { get; set; }
public decimal CarriedForwardDays { get; set; }
public decimal AdjustmentDays { get; set; }
public uint RowVersion { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Leave request (FR-HR-LV-02) — transactional document, numbered via
/// <see cref="NumberSequence"/> (docType "LV"). Model: docs/12-BACKEND-HRM.md Part C.5.
/// </summary>
public class LeaveRequest
{
public int LeaveRequestId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int LeaveTypeId { get; set; }
public LeaveType? LeaveType { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public decimal DaysCount { get; set; }
public string? Reason { get; set; }
public LeaveRequestStatus Status { get; set; } = LeaveRequestStatus.Draft;
public int? ApprovedBy { get; set; }
public DateTime? ApprovedAt { get; set; }
public string? RejectionReason { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>Leave type master (FR-HR-LV-01). Model: docs/12-BACKEND-HRM.md Part C.5.</summary>
public class LeaveType
{
public int LeaveTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsPaid { get; set; } = true;
/// <summary>Feeds Payroll's No-Pay deduction when true (docs/12-BACKEND-HRM.md §6).</summary>
public bool CountsAsNoPay { get; set; }
public decimal AccrualPerYear { get; set; }
public bool CarryForwardAllowed { get; set; }
public int? MaxCarryForwardDays { get; set; }
public bool RequiresApproval { get; set; } = true;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Loan installment ledger row. <see cref="PayrollRunId"/> is stamped only when the
/// consuming <see cref="PayrollRun"/> reaches Locked (docs/12-BACKEND-HRM.md A.4).
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class LoanInstallment
{
public int LoanInstallmentId { get; set; }
public int EmployeeLoanId { get; set; }
public EmployeeLoan? EmployeeLoan { get; set; }
public int InstallmentNumber { get; set; }
public int DueYear { get; set; }
public int DueMonth { get; set; }
public decimal ScheduledAmount { get; set; }
public decimal? PaidAmount { get; set; }
public int? PayrollRunId { get; set; }
public PayrollRun? PayrollRun { get; set; }
public LoanInstallmentStatus Status { get; set; } = LoanInstallmentStatus.Pending;
public uint RowVersion { get; set; }
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One row per successful sign-in, captured directly by ERPCore (not AuthHex) so an
/// admin can see a user's device/login history — AuthHex's own session listing only
/// ever reports the currently-authenticated caller's own sessions, with no admin-scoped
/// call to inspect anyone else's. This is a history of past logins, not a live "currently
/// active sessions" view (that would need AuthHex to expose one).
/// </summary>
public class LoginActivity
{
public int LoginActivityId { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
/// <summary>Best-effort "Browser on OS" label parsed from <see cref="UserAgent"/> for display.</summary>
public string? DeviceLabel { get; set; }
public DateTime LoginAt { get; set; }
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A single raised notification event (e.g. "GRN Confirmed"). Broadcast to
/// active users via <see cref="NotificationRecipient"/> rows, gated by
/// whether the module's InApp channel is enabled in <see cref="NotificationConfig"/>.
/// </summary>
public class Notification
{
public int NotificationId { get; set; }
public string ModuleKey { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string? RelatedEntityType { get; set; }
public int? RelatedEntityId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<NotificationRecipient> Recipients { get; set; } = new List<NotificationRecipient>();
}
@@ -0,0 +1,19 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Notification configuration (new settings module) - singleton root row.
/// </summary>
public class NotificationConfig
{
public const int SingletonId = 1;
public int NotificationConfigId { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
public uint RowVersion { get; set; }
public ICollection<NotificationConfigModule> Modules { get; set; } = new List<NotificationConfigModule>();
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Entities;
public class NotificationConfigModule
{
public int NotificationConfigModuleId { get; set; }
public int NotificationConfigId { get; set; }
public NotificationConfig? NotificationConfig { get; set; }
public string ModuleKey { get; set; } = string.Empty;
public bool EmailEnabled { get; set; }
public bool SmsEnabled { get; set; }
public bool InAppEnabled { get; set; }
}
@@ -0,0 +1,18 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per-user read state for a broadcast <see cref="Notification"/>.
/// </summary>
public class NotificationRecipient
{
public int NotificationRecipientId { get; set; }
public int NotificationId { get; set; }
public Notification? Notification { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public bool IsRead { get; set; }
public DateTime? ReadAt { get; set; }
}

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