Compare commits

...

139 Commits

Author SHA1 Message Date
DeepnaPooja 6b608ad2ef Update permission feature with edit and create role functionality 2026-10-19 16:39:47 +05:30
DeepnaPooja 3c75f9786e Add multi-series charts and enhance dashboard functionality 2026-10-19 15:51:39 +05:30
DeepnaPooja 2d6723a040 Update dashboard 2026-10-19 15:48:45 +05:30
kalana_Thilakarathna 2fe2ba007c Merge pull request 'fix: update login function to handle profile data correctly' (#68) from dev into production
Reviewed-on: #68
2026-10-19 10:25:57 +05:30
Kalana B. Thilakarathna f3f2b71bd4 fix: update login function to handle profile data correctly
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m43s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m30s
2026-10-19 10:25:29 +05:30
kalana_Thilakarathna bc72426495 Merge pull request 'auth fix' (#67) from dev into production
Reviewed-on: #67
2026-10-19 10:20:29 +05:30
Kalana B. Thilakarathna da29492798 auth fix
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m10s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m51s
2026-10-19 10:19:40 +05:30
kalana_Thilakarathna ce21aa6588 Merge pull request 'feat: add toast notifications for successful and failed login attempts' (#66) from dev into production
Reviewed-on: #66
2026-10-19 02:50:47 +05:30
Kalana B. Thilakarathna f44cbf711b feat: add toast notifications for successful and failed login attempts
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m53s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m41s
2026-10-19 02:50:06 +05:30
kalana_Thilakarathna aaac42b481 Merge pull request 'Dev' (#65) from dev into production
Reviewed-on: #65
2026-10-19 02:45:35 +05:30
Kalana B. Thilakarathna 7a21cde217 refactor: simplify user API calls by removing pageSize parameter in multiple components
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m52s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m5s
2026-10-19 02:44:19 +05:30
Kalana B. Thilakarathna 531b3a7fef Redesign login UI, add theme toggle, refactor user update
- Redesigned login page with two-panel layout and branding
- Added HexaDistro logo and theme toggle to sidebar/header
- Refactored user update: roles via update endpoint, removed assignRole API/controller
- Extended UpdateUserRequest for username, role, password changes
- UserService.UpdateAsync syncs changes with AuthHex, with rollback
- Stock balances client dedupes by itemId-locationId
- Middleware matcher excludes static assets from auth checks
- Improved localStorage clearing for user context
- Added lucide-react for icons
- Minor UI/code cleanups for consistency and UX
2026-10-19 02:20:14 +05:30
kalana_Thilakarathna a81e32bbe2 Merge pull request 'fe fix for loggin redirection' (#64) from dev into production
Reviewed-on: #64
2026-10-19 02:16:31 +05:30
Kalana B. Thilakarathna 4ef63420b7 fe fix for loggin redirection
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m36s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m19s
2026-10-18 23:38:28 +05:30
Kalana B. Thilakarathna 9fdf5aa8e3 Merge branch 'dev' into production 2026-10-18 23:23:12 +05:30
Kalana B. Thilakarathna d9c339949e conflict fixed 2026-10-18 23:22:04 +05:30
Kalana B. Thilakarathna 0052725b88 feat: add user and role view sheets with detailed information display
- Created UserViewSheet component to display user details including username, full name, role, status, and timestamps.
- Created RoleViewSheet component to display role details including name, level, status, creation date, and associated permissions.
- Introduced permissions configuration in a new permissions.ts file, defining categories and permission codes for better management.
- Implemented middleware for authentication, ensuring access control for protected routes.
2026-10-18 23:11:01 +05:30
DeepnaPooja 5d18aba1f9 Merge pull request 'Dev' (#63) from dev into production
Reviewed-on: #63
2026-10-16 10:53:09 +00:00
DeepnaPooja 5828879ddc Merge pull request 'chore: add exceljs, jspdf, and jspdf-autotable dependencies to package.json' (#62) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m0s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m11s
Reviewed-on: #62
2026-10-16 10:52:30 +00:00
DeepnaPooja d75a43d45f chore: add exceljs, jspdf, and jspdf-autotable dependencies to package.json 2026-10-16 16:16:43 +05:30
DeepnaPooja 728f2a8b17 Merge pull request 'Dev' (#61) from dev into production
Reviewed-on: #61
2026-10-16 10:26:15 +00:00
DeepnaPooja 1b66d5d767 Merge pull request 'Refactor/main flow' (#60) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m3s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m51s
Reviewed-on: #60
2026-10-16 10:25:08 +00:00
DeepnaPooja c5a0992b5d Fe changes 2026-10-16 15:47:23 +05:30
DeepnaPooja 09d86835a2 Implement Warehouse Dashboard features 2026-10-16 15:44:35 +05:30
DeepnaPooja d1d2d55352 Add stock report features r 2026-10-16 15:41:00 +05:30
DeepnaPooja c626f56cdb Refactor Return and Ride services to integrate StockMover and enhance error handling 2026-10-16 15:37:05 +05:30
DeepnaPooja 263e2ea9c0 Refactor Delivery and Purchase Return services to use StockMover for inventory movements 2026-10-16 15:34:25 +05:30
DeepnaPooja 5c37f02f19 Merge pull request 'Dev' (#59) from dev into production
Reviewed-on: #59
2026-10-09 04:38:19 +00:00
DeepnaPooja 6b3f28d3ae Merge pull request 'Refactor/main flow' (#58) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m37s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m0s
Reviewed-on: #58
2026-10-09 04:37:42 +00:00
Kalana B. Thilakarathna 558afc6f9d auth layer implemented 2026-10-09 09:58:45 +05:30
Kalana B. Thilakarathna 5b2ac8cc14 Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev 2026-10-09 09:37:38 +05:30
Kalana B. Thilakarathna 5cb32bee95 Revert "auth layer implemented"
This reverts commit 9adfff48a7.
2026-10-09 09:37:28 +05:30
Kalana B. Thilakarathna 9adfff48a7 auth layer implemented 2026-10-09 09:37:05 +05:30
DeepnaPooja e5f6aa15b9 Implement Store analytics and api integrations 2026-10-08 16:32:23 +05:30
DeepnaPooja 4e12b03e4b Add analytics functionality for stores 2026-10-08 16:28:56 +05:30
DeepnaPooja f5967497be Merge pull request 'Dev' (#57) from dev into production
Reviewed-on: #57
2026-10-08 07:36:24 +00:00
DeepnaPooja 74f33d8979 Merge pull request 'Refactor/main flow' (#56) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Successful in 1m42s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m55s
Reviewed-on: #56
2026-10-08 07:35:34 +00:00
DeepnaPooja dd8f50f54c Update docs for procurement and shop PO processes, including GRN rework functionality 2026-10-08 12:59:08 +05:30
DeepnaPooja 25b21d6fa8 Refactor GRN configuration page layout 2026-10-08 12:57:48 +05:30
DeepnaPooja 106ed57897 Implement GRN tax settings UI and calculations for VAT and NBT 2026-10-08 12:55:45 +05:30
DeepnaPooja 0451a0f418 Add documentation for GRN costing, taxes, and item tracking configuration 2026-10-08 12:54:38 +05:30
DeepnaPooja b12f3c8d08 Add NbtPercent column to GoodsReceipts and GoodsReceiptItems 2026-10-08 12:44:28 +05:30
DeepnaPooja 702efba62e Add GetNextSerialSequence functionality to Stock services and repository 2026-10-08 12:42:50 +05:30
DeepnaPooja 55aa35308a Add NBT support to GRN processing and pricing calculations 2026-10-08 12:41:44 +05:30
DeepnaPooja 40e890d4ff Fix GRN UI 2026-10-08 10:40:43 +05:30
DeepnaPooja 9908a552d1 Fix GRN pricing 2026-10-08 10:37:19 +05:30
DeepnaPooja 4110e2fcfc Merge pull request 'Dev' (#55) from dev into production
Reviewed-on: #55
2026-10-07 05:17:54 +00:00
DeepnaPooja 23fb7361a4 Merge pull request 'Refactor/main flow' (#54) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m8s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m25s
Reviewed-on: #54
2026-10-07 05:16:58 +00:00
DeepnaPooja ff6f3d3218 Add product reports 2026-10-07 10:41:04 +05:30
DeepnaPooja 92a311e1cc Add region manager assignment endpoints 2026-10-07 10:39:17 +05:30
DeepnaPooja 3538bb5b0e Assign a region manager for the route 2026-10-06 15:40:43 +05:30
DeepnaPooja b75cd01e90 Add region code support 2026-10-06 15:15:04 +05:30
DeepnaPooja c52ecfdebc Add region code 2026-10-06 15:09:22 +05:30
DeepnaPooja edb5fddc3a Add item tracking and the configuration docs 2026-10-06 13:32:37 +05:30
DeepnaPooja 05d44ab9f0 check stock lookup methods 2026-10-06 13:31:19 +05:30
DeepnaPooja c358764f4e Add item tracking migrations 2026-10-06 13:30:12 +05:30
DeepnaPooja 92f1aadb81 Add item and GRN tracking forms 2026-10-06 13:28:37 +05:30
DeepnaPooja a8725bc473 Add GRN batch and serial tracking 2026-10-06 13:26:17 +05:30
DeepnaPooja d578cbad20 fix routing 2026-10-02 14:18:39 +05:30
DeepnaPooja e1db9b1b69 add shared card and toggle components 2026-10-02 13:19:02 +05:30
DeepnaPooja 9707ef596c UI changes 2026-10-02 13:17:32 +05:30
DeepnaPooja bfd3e5cded add product configuration UI 2026-10-02 13:07:45 +05:30
DeepnaPooja d28d996295 Add product configurations 2026-10-02 13:06:22 +05:30
DeepnaPooja 4e493f51f8 Add item tracking and warranty settings 2026-10-02 13:03:45 +05:30
DeepnaPooja c52eaabec4 Merge pull request 'Dev' (#53) from dev into production
Reviewed-on: #53
2026-09-29 12:14:53 +00:00
DeepnaPooja 63c1c4ad1a Merge pull request 'fix' (#52) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 12m12s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m26s
Reviewed-on: #52
2026-09-29 12:14:19 +00:00
DeepnaPooja e3af6100ec fix 2026-09-29 17:38:39 +05:30
DeepnaPooja df0eb3cd5a Merge pull request 'Refactor/main flow' (#50) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m24s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m16s
Reviewed-on: #50
2026-09-29 12:04:26 +00:00
DeepnaPooja d8ba956817 fix loading sheet UI 2026-09-29 17:29:02 +05:30
DeepnaPooja 89c0c17be7 ui fix 2026-09-29 17:07:16 +05:30
DeepnaPooja cc72e22664 Merge pull request 'Refactor/main flow' (#49) from refactor/main_flow into dev
Reviewed-on: #49
2026-09-29 09:45:51 +00:00
DeepnaPooja ef4e0e0873 Add item reorder level migration 2026-09-29 12:32:22 +05:30
DeepnaPooja bd90a3d68e dashboard design 2026-09-29 12:08:54 +05:30
DeepnaPooja 7dac429a1a Add low stock endpoint 2026-09-29 11:48:37 +05:30
DeepnaPooja 3dbfe6db23 Add item reorder level to DTOs 2026-09-29 11:46:11 +05:30
DeepnaPooja a6de58e2e9 Add invoice payment summary 2026-09-29 11:45:08 +05:30
DeepnaPooja 681cf821a6 Add delivery task listing endpoint 2026-09-29 11:43:10 +05:30
DeepnaPooja 37f4a88014 Merge pull request 'Dev' (#48) from dev into production
Reviewed-on: #48
2026-09-28 07:39:55 +00:00
DeepnaPooja a4f5d12234 Merge pull request 'Refactor/main flow' (#47) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 12m10s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m24s
Reviewed-on: #47
2026-09-28 07:39:07 +00:00
DeepnaPooja ee50f0932d fix open add product in new tab 2026-09-28 13:03:11 +05:30
DeepnaPooja 7dd012f926 Add PO confirmation btutton and the status badge 2026-09-28 12:36:21 +05:30
DeepnaPooja aebfa6e3cc Add search to dropdown 2026-09-28 12:00:59 +05:30
DeepnaPooja 2d365cd906 Merge pull request 'Dev' (#46) from dev into production
Reviewed-on: #46
2026-09-25 08:38:12 +00:00
DeepnaPooja d6fd685b3c Merge pull request 'Add loading sheet edit flow' (#45) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m57s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m8s
Reviewed-on: #45
2026-09-25 08:37:39 +00:00
DeepnaPooja a892aec527 Add loading sheet edit flow 2026-09-25 14:02:24 +05:30
DeepnaPooja c68c6e1ab9 Merge pull request 'Dev' (#44) from dev into production
Reviewed-on: #44
2026-09-24 12:00:43 +00:00
DeepnaPooja aaf6e5f12d Merge pull request 'Refactor/main flow' (#43) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m24s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m36s
Reviewed-on: #43
2026-09-24 12:00:17 +00:00
DeepnaPooja 75b0a802c3 update the migrations 2026-09-24 17:24:43 +05:30
DeepnaPooja e8692b2474 fix the loading sheet view 2026-09-24 17:23:31 +05:30
DeepnaPooja be38777ebb Fixes for the route 2026-09-24 15:50:35 +05:30
DeepnaPooja 41f992b824 store page fixes 2026-09-24 15:05:48 +05:30
DeepnaPooja 1ec5426d15 PO shops fe fixes 2026-09-24 15:04:42 +05:30
DeepnaPooja 3b0a5f0d3f remove store kyc features 2026-09-24 14:56:17 +05:30
DeepnaPooja 96e12972b9 fix store profile fields 2026-09-24 14:18:52 +05:30
DeepnaPooja ddc3ecd3e6 Merge pull request 'Dev' (#42) from dev into production
Reviewed-on: #42
2026-09-23 08:38:21 +00:00
DeepnaPooja 556dedb8a3 Merge pull request 'Refactor/main flow' (#41) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m49s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m58s
Reviewed-on: #41
2026-09-23 08:36:57 +00:00
DeepnaPooja 80db899091 Fix user role bug for invoice actions 2026-09-23 14:01:32 +05:30
DeepnaPooja c290a1e778 Add user fallback for invoice actions 2026-09-23 13:59:49 +05:30
DeepnaPooja d7b6f29200 Merge pull request 'Dev' (#40) from dev into production
Reviewed-on: #40
2026-09-23 07:23:59 +00:00
DeepnaPooja 6f72f7a0e9 Merge pull request 'Refactor/main flow' (#39) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m8s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m21s
Reviewed-on: #39
2026-09-23 07:23:19 +00:00
DeepnaPooja ee672da9f7 Add user selection to Shop PO flow 2026-09-23 12:47:53 +05:30
DeepnaPooja 4b95e03e05 Add user fallback for Shop PO actions 2026-09-23 12:47:08 +05:30
DeepnaPooja ae302d0c9c Fix loading sheet creation flow 2026-09-23 12:46:23 +05:30
DeepnaPooja 53dcdf929a Merge pull request 'Refactor/main flow' (#37) from refactor/main_flow into dev
Reviewed-on: #37
2026-09-22 11:43:52 +00:00
DeepnaPooja d2277f626c Merge pull request 'Refactor/main flow' (#38) from refactor/main_flow into production
Reviewed-on: #38
2026-09-22 11:43:07 +00:00
DeepnaPooja 35fa3b19e3 Add loading sheet item selection
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m56s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m12s
2026-09-22 17:03:24 +05:30
DeepnaPooja b7db6a83e6 Add Shop PO management actions 2026-09-22 17:00:38 +05:30
DeepnaPooja 8bb3b3d192 Update loading sheet filters and orders 2026-09-22 16:59:38 +05:30
DeepnaPooja e5dadef863 Add invoice print and finalize flow 2026-09-22 16:58:38 +05:30
DeepnaPooja 44e335d542 Add ride cancellation details for the fe 2026-09-22 12:12:42 +05:30
DeepnaPooja d20e4a532f Add cancel ride type code and the cancel reason 2026-09-22 12:10:37 +05:30
DeepnaPooja 52845b5d87 Use client-side pagination for rides 2026-09-22 11:24:21 +05:30
kalana_Thilakarathna 3149902574 Merge pull request 'Dev' (#36) from dev into production
Reviewed-on: #36
2026-09-21 04:05:56 +00:00
Kalana B. Thilakarathna 3c17047550 Improve filter handling in SalesClient to reset values for empty selections
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m24s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m17s
2026-09-21 09:35:05 +05:30
Kalana B. Thilakarathna 34ffc7f97a Fix ride selection logic to handle empty values in LoadingSheetsClient 2026-09-21 09:30:57 +05:30
kalana_Thilakarathna 37fb72376a Merge pull request 'Dev' (#35) from dev into production
Reviewed-on: #35
2026-09-21 03:47:22 +00:00
kalana_Thilakarathna b3840b47ce Merge pull request 'Update sales pages' (#34) from refactor/FE-fixes into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 10m57s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m42s
Reviewed-on: #34
2026-09-21 03:46:58 +00:00
kalana_Thilakarathna 52a9a3fde7 Merge pull request 'Add build argument for NEXT_PUBLIC_API_URL in Dockerfile and CI/CD workflow' (#32) from dev into production
Reviewed-on: #32
2026-09-20 11:21:49 +00:00
Kalana B. Thilakarathna 3c2dee5b4a Add build argument for NEXT_PUBLIC_API_URL in Dockerfile and CI/CD workflow
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m32s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m25s
2026-09-20 16:51:13 +05:30
kalana_Thilakarathna 1b310c86b3 Merge pull request 'Add Dockerfile and .dockerignore; configure Next.js output settings' (#31) from dev into production
Reviewed-on: #31
2026-09-20 11:07:29 +00:00
Kalana B. Thilakarathna 1579936baf Add Dockerfile and .dockerignore; configure Next.js output settings
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 10m51s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m44s
2026-09-20 16:36:33 +05:30
kalana_Thilakarathna e398ee9d6b Merge pull request 'docker log' (#30) from dev into production
Reviewed-on: #30
2026-09-20 11:03:16 +00:00
Kalana B. Thilakarathna a8b6503a8a docker log
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m55s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m3s
2026-09-20 16:32:48 +05:30
kalana_Thilakarathna a3c0211654 Merge pull request 'pipline update' (#29) from dev into production
Reviewed-on: #29
2026-09-20 10:53:45 +00:00
Kalana B. Thilakarathna dc32ae7ea6 pipline update
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m27s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m34s
2026-09-20 16:23:16 +05:30
kalana_Thilakarathna e4e12aa05a Merge pull request 'build fix' (#28) from dev into production
Reviewed-on: #28
2026-09-20 10:42:24 +00:00
Kalana B. Thilakarathna 6a31ad0190 build fix
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 10m55s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m48s
2026-09-20 16:12:04 +05:30
kalana_Thilakarathna 3103fb66ef Merge pull request 'Dev' (#27) from dev into production
Reviewed-on: #27
2026-09-20 10:35:40 +00:00
Kalana B. Thilakarathna 502bf25cc0 Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m11s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m39s
2026-09-20 16:03:00 +05:30
Kalana B. Thilakarathna 484aa3fece Update appsettings.json URLs and add CI/CD workflow for ERP Distribution 2026-09-20 16:02:53 +05:30
Kalana B. Thilakarathna 93355d34f1 Merge branch 'dev' into production 2026-09-16 11:30:37 +05:30
Kalana B. Thilakarathna 9151f47d15 Merge branch 'production' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into production 2026-09-16 10:58:02 +05:30
Kalana B. Thilakarathna 661b522b7d Merge branch 'dev' into production 2026-09-16 10:57:55 +05:30
DeepnaPooja e0bb17d0ff Merge pull request 'Dev' (#25) from dev into production
Reviewed-on: #25
2026-09-16 05:13:39 +00:00
Kalana B. Thilakarathna 0f29413375 Merge branch 'dev' into production 2026-09-16 10:41:32 +05:30
DeepnaPooja ef17f7da0a Merge pull request 'Dev' (#23) from dev into production
Reviewed-on: #23
2026-09-14 06:29:20 +00:00
DeepnaPooja d9031f8e73 Merge pull request 'Dev' (#20) from dev into production
Reviewed-on: #20
2026-09-01 10:28:44 +00:00
Kalana B. Thilakarathna 75578ea7f6 Merge branch 'production' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into production 2026-09-01 10:54:20 +05:30
DeepnaPooja 4f02fa303d Merge pull request 'Dev' (#18) from dev into production
Reviewed-on: #18
2026-08-31 07:41:03 +00:00
333 changed files with 109608 additions and 3015 deletions
+89
View File
@@ -0,0 +1,89 @@
name: CI/CD - ERP Distribution
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.DISTRIBUTION }}@gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution.git \
.
- name: Restore
run: dotnet restore Distribution/Distribution.csproj
- name: Publish
run: |
rm -rf /tmp/erpdistribution-deploy
mkdir -p /tmp/erpdistribution-deploy
dotnet publish Distribution/Distribution.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpdistribution-deploy
- name: Stop service
run: systemctl stop erpdistribution.service
- name: Copy to deploy folder
run: |
mkdir -p /home/distribution/core/publish
rm -rf /home/distribution/core/publish/*
cp -r /tmp/erpdistribution-deploy/* /home/distribution/core/publish/
- name: Fix ownership and permissions
run: |
chown -R distributionuser:distributionuser /home/distribution/core/publish
find /home/distribution/core/publish -type d -exec chmod 755 {} +
find /home/distribution/core/publish -type f -exec chmod 644 {} +
chmod +x /home/distribution/core/publish/Distribution
- name: Restart service
run: systemctl restart erpdistribution.service
- name: Verify
run: |
systemctl is-active erpdistribution.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.DISTRIBUTION }}@gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution.git \
.
- name: Build Docker image
run: |
docker build --progress=plain --no-cache \
--build-arg NEXT_PUBLIC_API_URL=https://distribution.hexdive.com/api \
-t erpdistributionfe:latest \
./distribution_fe
- name: Stop & remove existing container
run: |
docker stop erpdistributionfe 2>/dev/null || true
docker rm erpdistributionfe 2>/dev/null || true
- name: Start container
run: |
docker run -d \
--name erpdistributionfe \
-p 8801:3000 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpdistributionfe:latest
- name: Remove old images
run: docker image prune -f
- name: Verify
run: |
docker ps --filter name=erpdistributionfe --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpdistributionfe && echo "✅ Container running" || echo "❌ Container failed"
@@ -18,6 +18,7 @@ public class RegionServiceTests
public RegionServiceTests()
{
_currentUser.Setup(u => u.RoleLevel).Returns(60);
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
}
@@ -148,6 +149,123 @@ public class RegionServiceTests
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
// Region code
[Fact]
public async Task CreateAsync_GeneratesFirstCode_WhenNoRegionsExist()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
Assert.Equal("REG-0001", result.Code);
}
[Fact]
public async Task CreateAsync_GeneratesNextCode_FromHighestExisting_IgnoringNonMatching()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0002", "REG-0009", "CUSTOM", "REG-abc", ""]);
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
Assert.Equal("REG-0010", result.Code);
}
[Fact]
public async Task CreateAsync_StoresProvidedCode_AsEntered()
{
var result = await _sut.CreateAsync(new CreateRegionRequest("North", " nth-1 "));
Assert.Equal("nth-1", result.Code);
_repo.Verify(r => r.GetAllCodesAsync(), Times.Never);
}
[Fact]
public async Task CreateAsync_Throws_WhenProvidedCodeInUse()
{
_repo.Setup(r => r.CodeExistsAsync("NTH-1", null)).ReturnsAsync(true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.CreateAsync(new CreateRegionRequest("North", "NTH-1")));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task GetNextCodeAsync_ReturnsSuggestion()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0003"]);
var result = await _sut.GetNextCodeAsync();
Assert.Equal("REG-0004", result.Code);
}
[Fact]
public async Task UpdateAsync_ChangesCode_StoredAsEntered()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.CodeExistsAsync("north-1", region.Id)).ReturnsAsync(false);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " north-1 "));
Assert.Equal("north-1", result!.Code);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task UpdateAsync_KeepsCode_WhenCodeBlank()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " "));
Assert.Equal("REG-0001", result!.Code);
_repo.Verify(r => r.CodeExistsAsync(It.IsAny<string>(), It.IsAny<Guid?>()), Times.Never);
}
[Fact]
public async Task UpdateAsync_Throws_WhenCodeUsedByAnotherRegion()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.CodeExistsAsync("REG-0002", region.Id)).ReturnsAsync(true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0002")));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task UpdateAsync_AllowsResubmittingOwnCode()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0001"));
Assert.Equal("REG-0001", result!.Code);
}
[Fact]
public async Task GetByIdAsync_ReturnsCode()
{
var region = ActiveRegion();
region.Code = "REG-0007";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.GetByIdAsync(region.Id);
Assert.Equal("REG-0007", result!.Code);
}
// DeleteAsync
[Fact]
@@ -195,4 +313,93 @@ public class RegionServiceTests
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
}
// Region manager
private static User ManagerUser(string role = "RegionalManager", bool active = true) => new()
{
Id = Guid.NewGuid(), Username = "mgr", FullName = "Mgr One", IsActive = active,
Role = new Role { Name = role, Level = 50 }
};
[Fact]
public async Task SetManagerAsync_ReturnsNull_WhenRegionMissing()
{
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Region?)null);
Assert.Null(await _sut.SetManagerAsync(Guid.NewGuid(), new SetRegionManagerRequest(Guid.NewGuid())));
}
[Fact]
public async Task SetManagerAsync_Throws_WhenUserNotRegionalManager()
{
var region = ActiveRegion();
var user = ManagerUser("SalesRep");
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id)));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task SetManagerAsync_ReplacesExistingManager_AndAddsScope()
{
var region = ActiveRegion();
var user = ManagerUser();
var old = new UserRegionScope { FK_UserId = Guid.NewGuid(), FK_RegionId = region.Id };
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([old]);
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync((UserRegionScope?)null);
var result = await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
Assert.Equal(user.Id, result!.UserId);
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => s.Single() == old)), Times.Once);
_repo.Verify(r => r.AddRegionScopeAsync(It.Is<UserRegionScope>(s => s.FK_UserId == user.Id && s.FK_RegionId == region.Id)), Times.Once);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task SetManagerAsync_DoesNotDuplicateScope_WhenAlreadyManager()
{
var region = ActiveRegion();
var user = ManagerUser();
var existing = new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id };
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([existing]);
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync(existing);
await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
_repo.Verify(r => r.AddRegionScopeAsync(It.IsAny<UserRegionScope>()), Times.Never);
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => !s.Any())), Times.Once);
}
[Fact]
public async Task RemoveManagerAsync_ReturnsFalse_WhenNoManager()
{
var region = ActiveRegion();
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([]);
Assert.False(await _sut.RemoveManagerAsync(region.Id));
}
[Fact]
public async Task GetManagerAsync_ReturnsManager()
{
var region = ActiveRegion();
var user = ManagerUser();
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id))
.ReturnsAsync([new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id, User = user }]);
var result = await _sut.GetManagerAsync(region.Id);
Assert.Equal("mgr", result!.Username);
}
}
+116 -3
View File
@@ -16,6 +16,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Uom> Uoms { get; set; }
public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<ItemBatch> ItemBatches { get; set; }
public DbSet<ItemSerialNumber> ItemSerialNumbers { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<ShopPO> ShopPOs { get; set; }
public DbSet<ShopPOItem> ShopPOItems { get; set; }
@@ -31,7 +33,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UserStoreScope> UserStoreScopes { get; set; }
public DbSet<StockBalance> StockBalances { get; set; }
public DbSet<StockMovement> StockMovements { get; set; }
public DbSet<StoreKycDocument> StoreKycDocuments { get; set; }
public DbSet<PriceList> PriceLists { get; set; }
public DbSet<PriceListItem> PriceListItems { get; set; }
public DbSet<Supplier> Suppliers { get; set; }
@@ -48,6 +49,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<GoodsReceiptItemBatch> GoodsReceiptItemBatches { get; set; }
public DbSet<GoodsReceiptItemSerial> GoodsReceiptItemSerials { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
public DbSet<SaleReturn> SaleReturns { get; set; }
@@ -84,8 +87,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
// --- Unique indexes ---
// NOTE: MySQL treats each NULL as distinct in a unique index, so this does not by itself
// prevent duplicate rows for untracked items (both FK columns null) — StockRepository's
// get-then-update pattern is what keeps those to one row per (Item, Location), same as before.
modelBuilder.Entity<StockBalance>()
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId, sb.FK_ItemBatchId, sb.FK_ItemSerialNumberId })
.IsUnique();
modelBuilder.Entity<ItemSerialNumber>()
.HasIndex(s => new { s.FK_ItemId, s.SerialNumber })
.IsUnique();
modelBuilder.Entity<PriceListItem>()
@@ -293,6 +303,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Supplier)
.WithMany()
.HasForeignKey(gr => gr.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Warehouse)
.WithMany()
@@ -311,12 +328,92 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
// --- ItemBatch / ItemSerialNumber ---
modelBuilder.Entity<ItemBatch>()
.HasOne(b => b.Item)
.WithMany()
.HasForeignKey(b => b.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ItemBatch>()
.HasIndex(b => new { b.FK_ItemId, b.BatchNumber });
modelBuilder.Entity<ItemSerialNumber>()
.HasOne(s => s.Item)
.WithMany()
.HasForeignKey(s => s.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
// --- StockBalance / StockMovement batch & serial links ---
modelBuilder.Entity<StockBalance>()
.HasOne(sb => sb.ItemBatch)
.WithMany()
.HasForeignKey(sb => sb.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockBalance>()
.HasOne(sb => sb.ItemSerialNumber)
.WithMany()
.HasForeignKey(sb => sb.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockMovement>()
.HasOne(sm => sm.ItemBatch)
.WithMany()
.HasForeignKey(sm => sm.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockMovement>()
.HasOne(sm => sm.ItemSerialNumber)
.WithMany()
.HasForeignKey(sm => sm.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- GoodsReceiptItemBatch / GoodsReceiptItemSerial ---
modelBuilder.Entity<GoodsReceiptItemBatch>()
.HasOne(b => b.GoodsReceiptItem)
.WithMany(i => i.Batches)
.HasForeignKey(b => b.FK_GoodsReceiptItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemBatch>()
.HasOne(b => b.ItemBatch)
.WithMany()
.HasForeignKey(b => b.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemSerial>()
.HasOne(s => s.GoodsReceiptItem)
.WithMany(i => i.Serials)
.HasForeignKey(s => s.FK_GoodsReceiptItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemSerial>()
.HasOne(s => s.ItemSerialNumber)
.WithMany()
.HasForeignKey(s => s.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict);
// --- PurchaseReturn ---
modelBuilder.Entity<PurchaseReturn>()
@@ -447,6 +544,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
// --- Regional ---
modelBuilder.Entity<Region>()
.Property(r => r.Code)
.HasMaxLength(50);
modelBuilder.Entity<Region>()
.HasIndex(r => r.Code)
.IsUnique();
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
@@ -479,7 +585,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasMaxLength(100);
modelBuilder.Entity<SysConfig>().HasData(
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null }
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeySerialNumberTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyBatchTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyExpiryDateTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyWarrantyTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyCustomWarrantyOverrideEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyDefaultWarrantyPeriodDays, Value = "0", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }
);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
@@ -488,5 +600,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
Seed.ProcurementSeed.Apply(modelBuilder);
Seed.CommerceSeed.Apply(modelBuilder);
Seed.OperationsExtrasSeed.Apply(modelBuilder);
Seed.AdminSeed.Apply(modelBuilder);
}
}
+25
View File
@@ -0,0 +1,25 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class AdminSeed
{
private static readonly DateTime SeedDate = new(2026, 10, 13);
// Same GUID as the AuthHex user — the sub claim from the JWT maps to this Id
internal static readonly Guid SuperAdminUserId = new("00000001-0000-0000-0000-000000000001");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(new User
{
Id = SuperAdminUserId,
Username = "superadmin",
FullName = "Super Admin",
FK_RoleId = RoleAndPermissionSeed.SuperAdminRoleId,
IsActive = true,
CreatedAt = SeedDate
});
}
}
+14 -18
View File
@@ -7,10 +7,6 @@ public static class MasterDataSeed
{
private static readonly DateTime SeedDate = new(2026, 8, 20);
// Precomputed via PasswordHasher<User>().HashPassword(new User(), "Passw0rd!123") — see
// Implementation Notes in the plan; not derivable from HasData at migration time.
internal const string SeedPasswordHash = "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==";
// Role ids match Data/Seed/RoleAndPermissionSeed.cs (private there, so re-declared here by value).
internal static readonly Guid SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
@@ -84,8 +80,8 @@ public static class MasterDataSeed
);
modelBuilder.Entity<Region>().HasData(
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
new Region { Id = WesternRegionId, Code = "REG-0001", Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Code = "REG-0002", Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Models.Route>().HasData(
@@ -96,12 +92,12 @@ public static class MasterDataSeed
);
modelBuilder.Entity<Store>().HasData(
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true }
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true },
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", CreatedAt = SeedDate, IsActive = true },
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true },
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true },
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", CreatedAt = SeedDate, IsActive = true },
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Warehouse>().HasData(
@@ -115,12 +111,12 @@ public static class MasterDataSeed
);
modelBuilder.Entity<User>().HasData(
new User { Id = Supervisor1UserId, Username = "sunil.fernando", PasswordHash = SeedPasswordHash, FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep1UserId, Username = "nimal.perera", PasswordHash = SeedPasswordHash, FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep2UserId, Username = "kamal.silva", PasswordHash = SeedPasswordHash, FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya", PasswordHash = SeedPasswordHash, FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver2UserId, Username = "priyantha.kumara", PasswordHash = SeedPasswordHash, FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa", PasswordHash = SeedPasswordHash, FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
new User { Id = Supervisor1UserId, Username = "sunil.fernando",FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep1UserId, Username = "nimal.perera",FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep2UserId, Username = "kamal.silva",FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya",FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver2UserId, Username = "priyantha.kumara",FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa",FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<DriverProfile>().HasData(
@@ -27,8 +27,6 @@ public static class OperationsExtrasSeed
private static readonly Guid StockMovement2Id = new("62000000-0000-0000-0000-000000000002");
private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003");
private static readonly Guid StoreKycDocument1Id = new("63000000-0000-0000-0000-000000000001");
private static readonly Guid StoreKycDocument2Id = new("63000000-0000-0000-0000-000000000002");
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
@@ -45,7 +43,7 @@ public static class OperationsExtrasSeed
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", PasswordHash = MasterDataSeed.SeedPasswordHash, FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
modelBuilder.Entity<Uom>().HasData(
@@ -83,12 +81,6 @@ public static class OperationsExtrasSeed
new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId },
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = StockService.ExternalLocationId, ToLocationType = LocationType.External, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
);
modelBuilder.Entity<StoreKycDocument>().HasData(
new StoreKycDocument { Id = StoreKycDocument1Id, FK_StoreId = MasterDataSeed.FortStoreId, DocumentType = "Business Registration", FileReference = "seed/kyc/fort-mini-mart-brc.pdf", UploadedAt = new DateTime(2026, 8, 10), CreatedAt = new DateTime(2026, 8, 10), IsActive = true },
new StoreKycDocument { Id = StoreKycDocument2Id, FK_StoreId = MasterDataSeed.WattalaStoreId, DocumentType = "NIC", FileReference = "seed/kyc/wattala-owner-nic.pdf", UploadedAt = new DateTime(2026, 8, 12), CreatedAt = new DateTime(2026, 8, 12), IsActive = true }
);
modelBuilder.Entity<DriverComplianceConfig>().HasData(
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
);
+181 -12
View File
@@ -7,22 +7,191 @@ public static class RoleAndPermissionSeed
{
private static readonly DateTime SeedDate = new(2026, 8, 18);
private static readonly Guid SalesRepId = new("10000000-0000-0000-0000-000000000001");
private static readonly Guid SupervisorId = new("10000000-0000-0000-0000-000000000002");
private static readonly Guid AreaManagerId = new("10000000-0000-0000-0000-000000000003");
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
private static readonly Guid SalesRepId = new("10000000-0000-0000-0000-000000000001");
private static readonly Guid SupervisorId = new("10000000-0000-0000-0000-000000000002");
private static readonly Guid AreaManagerId = new("10000000-0000-0000-0000-000000000003");
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
internal static readonly Guid SuperAdminRoleId = new("10000000-0000-0000-0000-000000000007");
// ── Legacy broad permissions (nav.ts uses these for sidebar visibility) ────
private static readonly Guid PermOrdersView = new("20000000-0000-0000-0000-000000000001");
private static readonly Guid PermRidesView = new("20000000-0000-0000-0000-000000000002");
private static readonly Guid PermLoadingView = new("20000000-0000-0000-0000-000000000003");
private static readonly Guid PermStoresView = new("20000000-0000-0000-0000-000000000004");
private static readonly Guid PermSalesVisitsCheckin = new("20000000-0000-0000-0000-000000000005");
private static readonly Guid PermItemsView = new("20000000-0000-0000-0000-000000000006");
private static readonly Guid PermCategoriesView = new("20000000-0000-0000-0000-000000000007");
private static readonly Guid PermWarehousesView = new("20000000-0000-0000-0000-000000000008");
private static readonly Guid PermPurchaseOrdersView = new("20000000-0000-0000-0000-000000000009");
private static readonly Guid PermPricelistsView = new("20000000-0000-0000-0000-000000000010");
private static readonly Guid PermSchemesView = new("20000000-0000-0000-0000-000000000011");
private static readonly Guid PermSuppliersView = new("20000000-0000-0000-0000-000000000012");
private static readonly Guid PermDriverProfilesView = new("20000000-0000-0000-0000-000000000013");
private static readonly Guid PermComplianceManage = new("20000000-0000-0000-0000-000000000014");
private static readonly Guid PermUsersView = new("20000000-0000-0000-0000-000000000015");
private static readonly Guid PermRolesView = new("20000000-0000-0000-0000-000000000016");
private static readonly Guid PermRegionsView = new("20000000-0000-0000-0000-000000000017");
private static readonly Guid PermRoutesView = new("20000000-0000-0000-0000-000000000018");
private static readonly Guid PermVehiclesView = new("20000000-0000-0000-0000-000000000019");
// ── Granular API permissions (used in [Authorize("...")] on controller actions) ─
private static readonly Guid PermClaimView = new("20000000-0000-0000-0000-000000000020");
private static readonly Guid PermClaimManage = new("20000000-0000-0000-0000-000000000021");
private static readonly Guid PermDeliveryTaskView = new("20000000-0000-0000-0000-000000000022");
private static readonly Guid PermDeliveryTaskManage = new("20000000-0000-0000-0000-000000000023");
private static readonly Guid PermDriverView = new("20000000-0000-0000-0000-000000000024");
private static readonly Guid PermDriverManage = new("20000000-0000-0000-0000-000000000025");
private static readonly Guid PermGrnView = new("20000000-0000-0000-0000-000000000026");
private static readonly Guid PermGrnManage = new("20000000-0000-0000-0000-000000000027");
private static readonly Guid PermLoadingSheetView = new("20000000-0000-0000-0000-000000000028");
private static readonly Guid PermLoadingSheetManage = new("20000000-0000-0000-0000-000000000029");
private static readonly Guid PermPriceListView = new("20000000-0000-0000-0000-000000000030");
private static readonly Guid PermPriceListManage = new("20000000-0000-0000-0000-000000000031");
private static readonly Guid PermPurchaseOrderView = new("20000000-0000-0000-0000-000000000032");
private static readonly Guid PermPurchaseOrderManage= new("20000000-0000-0000-0000-000000000033");
private static readonly Guid PermPurchaseReturnManage=new("20000000-0000-0000-0000-000000000034");
private static readonly Guid PermRegionView = new("20000000-0000-0000-0000-000000000035");
private static readonly Guid PermRegionManage = new("20000000-0000-0000-0000-000000000036");
private static readonly Guid PermReturnView = new("20000000-0000-0000-0000-000000000037");
private static readonly Guid PermReturnManage = new("20000000-0000-0000-0000-000000000038");
private static readonly Guid PermRouteView = new("20000000-0000-0000-0000-000000000039");
private static readonly Guid PermRouteManage = new("20000000-0000-0000-0000-000000000040");
private static readonly Guid PermSchemeView = new("20000000-0000-0000-0000-000000000041");
private static readonly Guid PermSchemeManage = new("20000000-0000-0000-0000-000000000042");
private static readonly Guid PermStockView = new("20000000-0000-0000-0000-000000000043");
private static readonly Guid PermStockManage = new("20000000-0000-0000-0000-000000000044");
private static readonly Guid PermStoreView = new("20000000-0000-0000-0000-000000000045");
private static readonly Guid PermStoreManage = new("20000000-0000-0000-0000-000000000046");
private static readonly Guid PermSupplierClaimView = new("20000000-0000-0000-0000-000000000047");
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Role>().HasData(
new Role { Id = SalesRepId, Name = "SalesRep", Level = 10, CreatedAt = SeedDate, IsActive = true },
new Role { Id = SupervisorId, Name = "Supervisor", Level = 20, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalManagerId, Name = "RegionalManager", Level = 50, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true }
new Role { Id = SalesRepId, Name = "SalesRep", Level = 10, CreatedAt = SeedDate, IsActive = true },
new Role { Id = SupervisorId, Name = "Supervisor", Level = 20, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalManagerId,Name = "RegionalManager",Level = 50, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true },
new Role { Id = SuperAdminRoleId, Name = "SuperAdmin", Level = 100, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Permission>().HasData(
// ── Legacy broad permissions ──────────────────────────────────────────
new Permission { Id = PermOrdersView, Code = "orders.view", Description = "View shop POs, invoices, payments and returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRidesView, Code = "rides.view", Description = "View and manage delivery rides and delivery tasks", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingView, Code = "loading.view", Description = "View and manage loading sheets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoresView, Code = "stores.view", Description = "View store accounts, credit limits and balances", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSalesVisitsCheckin, Code = "sales_visits.checkin", Description = "Check in and out of scheduled store visits", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermItemsView, Code = "items.view", Description = "View and manage catalogue products", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermCategoriesView, Code = "categories.view", Description = "View and manage product categories and brands", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermWarehousesView, Code = "warehouses.view", Description = "View stock balances, movements, warehouses and reports", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrdersView, Code = "purchase_orders.view", Description = "View and manage purchase orders, GRNs and supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPricelistsView, Code = "pricelists.view", Description = "View and manage regional price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemesView, Code = "schemes.view", Description = "View and manage pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSuppliersView, Code = "suppliers.view", Description = "View and manage supplier accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverProfilesView, Code = "driver_profiles.view", Description = "View and manage driver profiles and licensing", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermComplianceManage, Code = "compliance.manage", Description = "Manage driver compliance rules", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermUsersView, Code = "users.view", Description = "View and manage staff user accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRolesView, Code = "roles.view", Description = "View and manage roles and their permission sets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionsView, Code = "regions.view", Description = "View and manage regions and region configuration", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRoutesView, Code = "routes.view", Description = "View and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermVehiclesView, Code = "vehicles.view", Description = "View and manage fleet vehicles", CreatedAt = SeedDate, IsActive = true },
// ── Granular API permissions ──────────────────────────────────────────
new Permission { Id = PermClaimView, Code = "claim.view", Description = "View agency claims and settlement status", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermClaimManage, Code = "claim.manage", Description = "Create and settle agency claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDeliveryTaskView, Code = "delivery-task.view", Description = "View delivery tasks within a ride", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDeliveryTaskManage, Code = "delivery-task.manage", Description = "Confirm and fail delivery tasks", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverView, Code = "driver.view", Description = "View driver profiles and license details", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverManage, Code = "driver.manage", Description = "Create and update driver profiles", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGrnView, Code = "grn.view", Description = "View goods receipt notes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGrnManage, Code = "grn.manage", Description = "Create and confirm goods receipts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingSheetView, Code = "loading-sheet.view", Description = "View loading sheets and their items", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingSheetManage, Code = "loading-sheet.manage", Description = "Create, edit, and confirm loading sheets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPriceListView, Code = "price-list.view", Description = "View regional price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPriceListManage, Code = "price-list.manage", Description = "Create and manage item price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrderView, Code = "purchase-order.view", Description = "View purchase orders and line items", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrderManage,Code = "purchase-order.manage", Description = "Create, confirm, and close purchase orders", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseReturnManage,Code= "purchase-return.manage",Description = "Create and manage purchase returns to suppliers", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionView, Code = "region.view", Description = "View regions and their configuration", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionManage, Code = "region.manage", Description = "Create and update regions, targets, and budgets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermReturnView, Code = "return.view", Description = "View order returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermReturnManage, Code = "return.manage", Description = "Create and manage order returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRouteView, Code = "route.view", Description = "View delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRouteManage, Code = "route.manage", Description = "Create and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemeView, Code = "scheme.view", Description = "View pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemeManage, Code = "scheme.manage", Description = "Create and configure pricing schemes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStockView, Code = "stock.view", Description = "View stock balances and movements", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStockManage, Code = "stock.manage", Description = "Transfer stock and record damage or adjustments", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoreView, Code = "store.view", Description = "View store accounts and balances", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoreManage, Code = "store.manage", Description = "Create and update store accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
);
// SuperAdmin gets every permission
modelBuilder.Entity<RolePermission>().HasData(
// ── Legacy broad permissions ──────────────────────────────────────────
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermOrdersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRidesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoresView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSalesVisitsCheckin, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermItemsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermCategoriesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermWarehousesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrdersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPricelistsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSuppliersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverProfilesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermComplianceManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermUsersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRolesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRoutesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermVehiclesView, CreatedAt = SeedDate, IsActive = true },
// ── Granular API permissions ──────────────────────────────────────────
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseReturnManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
);
}
}
+73 -10
View File
@@ -1,3 +1,5 @@
using System.IdentityModel.Tokens.Jwt;
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -6,32 +8,93 @@ namespace Distribution.Features.Auth;
[ApiController]
[Route("api/auth")]
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
public class AuthController(AuthService service, ICurrentUserService currentUser, IUserRepository userRepo) : ControllerBase
{
private static IActionResult ToActionResult(AuthHexResponse result) =>
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
ToActionResult(await service.LoginAsync(request, ct));
[AllowAnonymous]
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct)
{
var result = await service.LoginAsync(request, ct);
if (!result.Success || result.Data is not { } data)
return ToActionResult(result);
string? token = null;
TimeSpan maxAge = TimeSpan.FromHours(1);
if (data.TryGetProperty("accessToken", out var tokenEl))
token = tokenEl.GetString();
if (data.TryGetProperty("expiresIn", out var expEl))
maxAge = TimeSpan.FromSeconds(expEl.GetInt32());
if (!string.IsNullOrEmpty(token))
{
Response.Cookies.Append("access-token", token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = maxAge,
Secure = Request.IsHttps,
});
}
MeResponse? profile = null;
if (!string.IsNullOrEmpty(token))
{
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
var userIdStr = jwt.Claims.FirstOrDefault(c => c.Type == "UserId")?.Value;
if (Guid.TryParse(userIdStr, out var userId))
{
var user = await userRepo.GetByIdAsync(userId);
if (user is not null)
profile = new MeResponse(
user.Id,
user.Username,
user.FK_RoleId,
user.Role?.Name ?? string.Empty,
user.Role?.Level ?? 0);
}
}
return Ok(new { result.StatusCode, result.Success, result.Message, result.Data, profile });
}
[HttpPost("register")]
[AllowAnonymous]
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
ToActionResult(await service.RegisterAsync(request, ct));
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me(CancellationToken ct)
public async Task<IActionResult> Me()
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
if (currentUser.UserId is null) return Unauthorized();
var user = await userRepo.GetByIdAsync(currentUser.UserId.Value);
if (user is null) return Unauthorized();
return Ok(new MeResponse(
user.Id,
user.Username,
user.FK_RoleId,
user.Role?.Name ?? string.Empty,
user.Role?.Level ?? 0));
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout(CancellationToken ct)
[AllowAnonymous]
public IActionResult Logout()
{
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
return ToActionResult(await service.LogoutAsync(token, ct));
Response.Cookies.Delete("access-token", new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
Secure = Request.IsHttps,
});
return Ok(new { success = true });
}
}
+13 -2
View File
@@ -1,5 +1,16 @@
namespace Distribution.Features.Auth;
public record LoginRequest(string Username, string Password);
public record LoginRequest(
string Identifier,
string Password,
Guid? UserTypeId = null,
string DeviceName = "Unknown Device");
public record RegisterRequest(string Username, string Password, string? FullName);
public record RegisterRequest(string Username, string email, string Password, string? FullName);
public record MeResponse(
Guid UserId,
string Username,
Guid RoleId,
string RoleCode,
int RoleLevel);
@@ -1,12 +1,11 @@
using System.Net.Http.Json;
namespace Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
/// <summary>
/// Proxies own-user auth calls (login/register/getUserDetails/logout) to the external AuthHex
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates
/// (see JwtTokenService's replacement — token generation is no longer done locally); this app
/// never sees or stores a plaintext/hashed password for the AuthHex-authenticated flow.
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates;
/// this app never sees or stores a plaintext/hashed password.
/// </summary>
public class AuthHexClient(HttpClient http) : IAuthHexClient
{
@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
/// <summary>Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName.</summary>
public record AuthHexResponse(
+8 -4
View File
@@ -1,17 +1,21 @@
using Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
/// <summary>
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates.
/// </summary>
public class AuthService(IAuthHexClient authHex)
{
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
authHex.CallAsync("loginUser", new
{
identifier = request.Identifier,
password = request.Password,
userTypeId = request.UserTypeId,
deviceName = request.DeviceName
}, reference: Guid.NewGuid().ToString(), ct: ct);
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
@@ -1,20 +0,0 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Typed HttpClient that proxies requests to the external Auth Service.
/// Registered via AddHttpClient&lt;AuthServiceClient&gt; in Program.cs.
/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
///
/// TODO: Implement methods once Auth Service contract is agreed.
/// </summary>
public class AuthServiceClient(HttpClient http) : IAuthServiceClient
{
// TODO: implement proxy methods, e.g.:
//
// public async Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct)
// {
// var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
// if (!response.IsSuccessStatusCode) return null;
// return await response.Content.ReadFromJsonAsync<AuthServiceResponse>(ct);
// }
}
@@ -1,11 +0,0 @@
namespace Distribution.Features.Auth;
public class AuthServiceOptions
{
public const string SectionName = "AuthService";
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
public string BaseUrl { get; set; } = string.Empty;
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
}
@@ -1,4 +1,4 @@
namespace Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
public interface IAuthHexClient
{
@@ -1,16 +0,0 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Connector to the external Auth Service. This backend proxies all auth
/// traffic from the Next.js frontend through to the Auth Service — the
/// frontend never calls the Auth Service directly.
///
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
/// </summary>
public interface IAuthServiceClient
{
// TODO: define methods based on Auth Service contract, e.g.:
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
// Task LogoutAsync(string token, CancellationToken ct);
}
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Claims;
@@ -7,10 +8,12 @@ namespace Distribution.Features.Claims;
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/claims")]
[Authorize("claim.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/claims/{id:guid}")]
[Authorize("claim.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,6 +21,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPost("api/claims")]
[Authorize("claim.manage")]
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
{
try
@@ -29,6 +33,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPut("api/claims/{id:guid}")]
[Authorize("claim.manage")]
public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct)
{
try
@@ -40,6 +45,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPost("api/claims/{id:guid}/settle")]
[Authorize("claim.manage")]
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.SettledByUserId;
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DashboardReport;
[ApiController]
[Route("api/reports/dashboard")]
public class DashboardReportController(DashboardReportService service) : ControllerBase
{
[HttpGet("sales")]
public async Task<IActionResult> Sales(
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] Guid? regionId, [FromQuery] Guid? categoryId)
{
var f = DashboardReportService.BuildFilter(from, to, regionId, categoryId, null);
var error = DashboardReportService.Validate(f);
return error is null ? Ok(await service.GetSalesSummaryAsync(f)) : BadRequest(new { message = error });
}
[HttpGet("inventory")]
public async Task<IActionResult> Inventory(
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] Guid? warehouseId, [FromQuery] Guid? categoryId)
{
var f = DashboardReportService.BuildFilter(from, to, null, categoryId, warehouseId);
var error = DashboardReportService.Validate(f);
return error is null ? Ok(await service.GetInventorySummaryAsync(f)) : BadRequest(new { message = error });
}
}
@@ -0,0 +1,32 @@
namespace Distribution.Features.DashboardReport;
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
public record SalesTotals(decimal GrossSales, decimal ReturnValue, int Orders, decimal CostedSales, decimal CostOfSales);
public record SalesKpis(
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue,
decimal GrossProfit, decimal? GrossProfitMarginPercent);
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
public record TrendPoint(string Period, decimal Sales, decimal Returns);
public record NamedAmount(Guid? Id, string Name, decimal Amount);
public record SalesSummaryResponse(
DateTime From, DateTime To, string Granularity,
SalesKpiComparison Kpis, List<TrendPoint> Trend,
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
public record PeriodAmount(DateTime Date, decimal Amount);
public record MovementPoint(string Period, decimal Received, decimal Issued, decimal Net);
public record WarehouseStockRow(Guid WarehouseId, string Name, decimal Quantity, decimal Value);
public record CategoryValueRow(string Category, decimal Quantity, decimal Value);
public record InventorySummaryResponse(
DateTime From, DateTime To, string Granularity,
List<CategoryValueRow> ValueByCategory, List<WarehouseStockRow> ByWarehouse, List<MovementPoint> MovementTrend);
@@ -0,0 +1,171 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DashboardReport;
public class DashboardReportRepository(AppDBContext db) : IDashboardReportRepository
{
private sealed class SoldLine
{
public DateTime Date { get; set; }
public Guid PoId { get; set; }
public decimal Value { get; set; }
public decimal? Cost { get; set; }
public Guid CategoryId { get; set; }
public string CategoryName { get; set; } = "";
public Guid RegionId { get; set; }
public string RegionName { get; set; } = "";
public Guid RepId { get; set; }
}
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q =
from li in db.ShopPOItems.AsNoTracking()
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
where inv.Status == InvoiceStatus.Finalized
&& li.ShopPO.Status != ShopPOStatus.Cancelled
&& inv.FinalizedAt >= start && inv.FinalizedAt < end
select new SoldLine
{
Date = inv.FinalizedAt!.Value.Date,
PoId = li.FK_ShopPOId,
Value = li.QuantityFulfilled * li.UnitPrice,
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice,
CategoryId = li.Item.FK_CategoryId,
CategoryName = li.Item.Category.Name,
RegionId = li.ShopPO.Store.Route.FK_RegionId,
RegionName = li.ShopPO.Store.Route.Region.Name,
RepId = li.ShopPO.RaisedById,
};
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
return q;
}
private IQueryable<SaleReturnItem> ReturnLines(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q = db.SaleReturnItems.AsNoTracking()
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end);
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
return q;
}
public async Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f)
{
var lines = SoldLines(f);
var gross = await lines.SumAsync(l => (decimal?)l.Value) ?? 0m;
var orders = await lines.Select(l => l.PoId).Distinct().CountAsync();
var costed = lines.Where(l => l.Cost != null);
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
return new SalesTotals(gross, returns, orders, costedSales, cost);
}
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => l.Date)
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Value) })
.ToListAsync())
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
public async Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f) =>
(await ReturnLines(f).GroupBy(r => r.SaleReturn.CreatedAt.Date)
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Quantity * x.UnitPrice) })
.ToListAsync())
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => new { l.CategoryId, l.CategoryName })
.Select(g => new { g.Key.CategoryId, g.Key.CategoryName, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).ToListAsync())
.Select(x => new GroupAggregate(x.CategoryId, x.CategoryName, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => new { l.RegionId, l.RegionName })
.Select(g => new { g.Key.RegionId, g.Key.RegionName, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).ToListAsync())
.Select(x => new GroupAggregate(x.RegionId, x.RegionName, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f)
{
var rows = await SoldLines(f).GroupBy(l => l.RepId)
.Select(g => new { RepId = g.Key, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).Take(20).ToListAsync();
var ids = rows.Select(r => r.RepId).ToList();
var names = await db.Users.AsNoTracking().Where(u => ids.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName);
return rows.Select(r => new GroupAggregate(r.RepId, names.GetValueOrDefault(r.RepId, "Unknown"), r.Amount)).ToList();
}
private IQueryable<StockBalance> Balances(DashboardFilter f)
{
var q = db.StockBalances.AsNoTracking().AsQueryable();
if (f.WarehouseId.HasValue)
q = q.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
if (f.CategoryId.HasValue) q = q.Where(b => b.Item.FK_CategoryId == f.CategoryId.Value);
return q;
}
// Value = quantity x cost price; items without a cost price contribute quantity only.
public async Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f) =>
(await Balances(f).GroupBy(b => b.Item.Category.Name)
.Select(g => new
{
Category = g.Key,
Quantity = g.Sum(x => x.Quantity),
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
})
.OrderByDescending(x => x.Value).ToListAsync())
.Select(x => new CategoryValueRow(x.Category, x.Quantity, x.Value)).ToList();
public async Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f)
{
var rows = await Balances(f).Where(b => b.LocationType == LocationType.Warehouse)
.GroupBy(b => b.LocationId)
.Select(g => new
{
Id = g.Key,
Quantity = g.Sum(x => x.Quantity),
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
})
.ToListAsync();
var ids = rows.Select(r => r.Id).ToList();
var names = await db.Warehouses.AsNoTracking().Where(w => ids.Contains(w.Id))
.ToDictionaryAsync(w => w.Id, w => w.Name);
return rows.Select(r => new WarehouseStockRow(r.Id, names.GetValueOrDefault(r.Id, "Unknown"), r.Quantity, r.Value))
.OrderByDescending(r => r.Value).ToList();
}
// Received = purchase receipts + customer returns; Issued = sales + purchase returns + damages.
// Transfers, loads and adjustments are internal/signed and excluded from this trend.
public async Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q = db.StockMovements.AsNoTracking()
.Where(m => m.CreatedAt >= start && m.CreatedAt < end
&& (m.Type == MovementType.PurchaseReceipt || m.Type == MovementType.CustomerReturn
|| m.Type == MovementType.Sale || m.Type == MovementType.PurchaseReturn || m.Type == MovementType.Damage));
if (f.CategoryId.HasValue) q = q.Where(m => m.Item.FK_CategoryId == f.CategoryId.Value);
if (f.WarehouseId.HasValue)
q = q.Where(m => m.ToLocationId == f.WarehouseId.Value || m.FromLocationId == f.WarehouseId.Value);
var rows = await q.GroupBy(m => new { m.CreatedAt.Date, m.Type })
.Select(g => new { g.Key.Date, g.Key.Type, Qty = g.Sum(x => x.Quantity) })
.ToListAsync();
return rows.GroupBy(r => r.Date)
.Select(g => (g.Key,
g.Where(r => r.Type is MovementType.PurchaseReceipt or MovementType.CustomerReturn).Sum(r => r.Qty),
g.Where(r => r.Type is MovementType.Sale or MovementType.PurchaseReturn or MovementType.Damage).Sum(r => r.Qty)))
.ToList();
}
}
@@ -0,0 +1,83 @@
namespace Distribution.Features.DashboardReport;
public class DashboardReportService(IDashboardReportRepository repo)
{
public const int MaxRangeDays = 366;
private const int DailyGranularityMaxDays = 62;
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
{
var end = (to ?? DateTime.Now).Date;
var start = (from ?? end.AddDays(-29)).Date;
return new DashboardFilter(start, end, regionId, categoryId, warehouseId);
}
public static string? Validate(DashboardFilter f)
{
if (f.To < f.From) return "'to' must not be before 'from'.";
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
return null;
}
private static bool Daily(DashboardFilter f) => (f.To - f.From).TotalDays <= DailyGranularityMaxDays;
private static string Bucket(DateTime d, bool daily) => daily ? d.ToString("yyyy-MM-dd") : d.ToString("yyyy-MM");
private static SalesKpis ToKpis(SalesTotals t)
{
var net = t.GrossSales - t.ReturnValue;
var gp = t.CostedSales - t.CostOfSales;
return new SalesKpis(
t.GrossSales, t.ReturnValue, net, t.Orders,
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
}
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
{
var days = (f.To - f.From).Days + 1;
var prev = f with { From = f.From.AddDays(-days), To = f.From.AddDays(-1) };
var daily = Daily(f);
var current = await repo.GetSalesTotalsAsync(f);
var previous = await repo.GetSalesTotalsAsync(prev);
var sales = await repo.GetDailySalesAsync(f);
var returns = await repo.GetDailyReturnsAsync(f);
var trend = sales.Select(s => (s.Date, Sales: s.Amount, Returns: 0m))
.Concat(returns.Select(r => (r.Date, Sales: 0m, Returns: r.Amount)))
.GroupBy(x => Bucket(x.Date, daily))
.OrderBy(g => g.Key)
.Select(g => new TrendPoint(g.Key, g.Sum(x => x.Sales), g.Sum(x => x.Returns)))
.ToList();
static NamedAmount Map(GroupAggregate g) => new(g.Key, g.Name, g.Amount);
return new SalesSummaryResponse(
f.From, f.To, daily ? "day" : "month",
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
trend,
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
(await repo.GetSalesByRepAsync(f)).Select(Map).ToList());
}
public async Task<InventorySummaryResponse> GetInventorySummaryAsync(DashboardFilter f)
{
var daily = Daily(f);
var movements = (await repo.GetDailyMovementsAsync(f))
.GroupBy(m => Bucket(m.Date, daily))
.OrderBy(g => g.Key)
.Select(g =>
{
var rec = g.Sum(x => x.Received);
var iss = g.Sum(x => x.Issued);
return new MovementPoint(g.Key, rec, iss, rec - iss);
}).ToList();
return new InventorySummaryResponse(
f.From, f.To, daily ? "day" : "month",
await repo.GetStockValueByCategoryAsync(f),
await repo.GetStockByWarehouseAsync(f),
movements);
}
}
@@ -0,0 +1,16 @@
namespace Distribution.Features.DashboardReport;
public record GroupAggregate(Guid? Key, string Name, decimal Amount);
public interface IDashboardReportRepository
{
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f);
Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f);
Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f);
Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f);
}
@@ -1,12 +1,16 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
[ApiController]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpPost("rides/{rideId:guid}/tasks/generate")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
{
try
@@ -20,13 +24,26 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
[HttpGet("rides/{rideId:guid}/tasks")]
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
[Authorize("delivery-task.view")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
return Ok(result);
}
[HttpGet("delivery-tasks")]
//[Authorize("delivery-task.view")]
public async Task<IActionResult> GetAll(
[FromQuery] DeliveryTaskStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(status, page, pageSize);
return Ok(result);
}
[HttpGet("delivery-tasks/{id:guid}")]
[Authorize("delivery-task.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -34,6 +51,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
}
[HttpPut("delivery-tasks/{id:guid}/quantities")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{
try
@@ -46,11 +64,12 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
}
[HttpPost("delivery-tasks/{id:guid}/confirm")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, ct);
var result = await service.ConfirmAsync(id, currentUser.UserId ?? Guid.Empty, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -58,6 +77,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
}
[HttpPost("delivery-tasks/{id:guid}/fail")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{
try
@@ -26,6 +26,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
.OrderBy(t => t.Sequence)
.ToListAsync();
public Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
{
var query = context.DeliveryTasks
.Include(t => t.ShopPO)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.AsQueryable();
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
return query.OrderByDescending(t => t.CreatedAt)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
public Task<int> GetCountAsync(DeliveryTaskStatus? status)
{
var query = context.DeliveryTasks.AsQueryable();
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
return query.CountAsync();
}
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
@@ -1,3 +1,4 @@
using Distribution.Features.LoadingEvent;
using Distribution.Features.Ride;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
@@ -10,7 +11,8 @@ public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IShopPORepository shopPORepo,
IStockRepository stockRepo,
ILoadingSheetRepository loadingSheetRepo,
StockMover mover,
IUnitOfWork uow)
{
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
@@ -32,8 +34,52 @@ public class DeliveryTaskService(
i.UnitPrice)).ToList(),
t.CreatedAt);
public Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default) =>
throw new NotImplementedException("Delivery task auto-generation is deferred — route-based dispatch not yet implemented.");
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
var confirmedSheets = await loadingSheetRepo.GetAllAsync(rideId, LoadingSheetStatus.Confirmed, null, null, 1, int.MaxValue);
if (confirmedSheets.Count == 0)
throw new InvalidOperationException("Ride has no confirmed loading sheet to generate delivery tasks from");
var shopPOIds = confirmedSheets
.SelectMany(s => s.Orders)
.Select(o => o.FK_ShopPOId)
.Distinct()
.ToList();
var existingTasks = await repo.GetByRideIdAsync(rideId);
var nextSequence = existingTasks.Count > 0 ? existingTasks.Max(t => t.Sequence) + 1 : 1;
var newTasks = new List<DeliveryTask>();
foreach (var shopPOId in shopPOIds)
{
if (await repo.ExistsForOrderAndRideAsync(shopPOId, rideId)) continue;
newTasks.Add(new DeliveryTask
{
FK_RideId = rideId,
FK_ShopPOId = shopPOId,
Sequence = nextSequence++,
Status = DeliveryTaskStatus.Pending
});
}
if (newTasks.Count > 0)
{
await uow.BeginAsync(ct);
try
{
foreach (var task in newTasks) await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
}
var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList();
}
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
{
@@ -41,6 +87,13 @@ public class DeliveryTaskService(
return tasks.Select(ToResponse).ToList();
}
public async Task<PagedResponse<DeliveryTaskResponse>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
{
var items = await repo.GetAllAsync(status, page, pageSize);
var total = await repo.GetCountAsync(status);
return new PagedResponse<DeliveryTaskResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
{
var task = await repo.GetByIdAsync(id);
@@ -81,7 +134,7 @@ public class DeliveryTaskService(
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, Guid userId, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
@@ -113,29 +166,12 @@ public class DeliveryTaskService(
{
foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
var availableQuantity = vehicleBalance?.Quantity ?? 0;
var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
if (availableQuantity < item.QuantityFulfilled)
throw new InvalidOperationException(
$"Insufficient vehicle stock for item '{itemName}': available {availableQuantity}, required {item.QuantityFulfilled}");
vehicleBalance!.Quantity -= item.QuantityFulfilled;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId,
FromLocationType = LocationType.Vehicle,
ToLocationId = StockService.ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_ShopPOId
});
await mover.MoveAsync(item.FK_ItemId, itemName,
vehicleId, LocationType.Vehicle,
StockService.ExternalLocationId, LocationType.External,
item.QuantityFulfilled, MovementType.Sale, task.FK_ShopPOId, userId);
}
repo.Update(task);
@@ -6,6 +6,8 @@ public interface IDeliveryTaskRepository
{
Task<DeliveryTask?> GetByIdAsync(Guid id);
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize);
Task<int> GetCountAsync(DeliveryTaskStatus? status);
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
Task AddAsync(DeliveryTask task);
void Update(DeliveryTask task);
@@ -1,5 +1,6 @@
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverProfile;
@@ -12,6 +13,7 @@ public class DriverProfileController(
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("driver.view")]
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(page, pageSize);
@@ -19,6 +21,7 @@ public class DriverProfileController(
}
[HttpGet("{id:guid}")]
[Authorize("driver.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -26,6 +29,7 @@ public class DriverProfileController(
}
[HttpPost]
[Authorize("driver.manage")]
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
{
try
@@ -44,6 +48,7 @@ public class DriverProfileController(
}
[HttpPut("{id:guid}")]
[Authorize("driver.manage")]
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -51,6 +56,7 @@ public class DriverProfileController(
}
[HttpDelete("{id:guid}")]
[Authorize("driver.manage")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var success = await service.DeactivateAsync(id, ct);
@@ -58,6 +64,7 @@ public class DriverProfileController(
}
[HttpGet("compliance")]
[Authorize("driver.view")]
public async Task<IActionResult> GetCompliance()
{
var result = await complianceService.GetAsync();
@@ -65,6 +72,7 @@ public class DriverProfileController(
}
[HttpPut("compliance")]
[Authorize("driver.manage")]
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
+24 -2
View File
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Grn;
@@ -7,6 +8,7 @@ namespace Distribution.Features.Grn;
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/goods-receipts")]
[Authorize("grn.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? purchaseOrderId,
[FromQuery] int page = 1,
@@ -17,6 +19,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
}
[HttpGet("api/goods-receipts/{id:guid}")]
[Authorize("grn.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -24,6 +27,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
}
[HttpPost("api/goods-receipts")]
[Authorize("grn.manage")]
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -32,18 +36,36 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
try
{
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
var inner = new CreateGrnRequest(request.WarehouseId, request.Items,
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent, NbtPercent: request.NbtPercent);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
/// <summary>Goods received without a purchase order.</summary>
[HttpPost("api/goods-receipts/direct")]
[Authorize("grn.manage")]
public async Task<IActionResult> CreateDirect(CreateDirectGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ReceivedByUserId;
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateDirectAsync(request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
[Authorize("grn.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
[Authorize("grn.manage")]
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ReceivedByUserId;
+76 -7
View File
@@ -1,35 +1,104 @@
namespace Distribution.Features.Grn;
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
/// <summary>BatchNumber is null for an expiry-only lot (TracksExpiryDate without TracksBatches).</summary>
public record GrnBatchLineRequest(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
public record GrnItemRequest(
// Set for a PO-based receipt; null for a direct receipt (ItemId is used instead).
Guid? PurchaseOrderItemId,
int AcceptedQuantity,
int RejectedQuantity,
// Populated only when the item has TracksBatches and/or TracksExpiryDate set.
List<GrnBatchLineRequest>? Batches = null,
// Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries.
List<string>? SerialNumbers = null,
// Set for a direct receipt line (no purchase order).
Guid? ItemId = null,
// Cost per base-UOM unit. Null on a PO line falls back to the PO line's unit cost; null on a direct line is 0.
decimal? UnitCost = null,
// Null falls back to the GRN-wide percentage.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
// Free-issue units: stocked but not costed. Serial/batch entries must cover accepted + free.
int FreeQuantity = 0,
// Ignored when NBT is disabled in configuration; null falls back to the GRN-wide percentage.
decimal? NbtPercent = null);
public record CreateGrnRequest(
Guid WarehouseId,
List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null);
Guid? ReceivedByUserId = null,
// GRN-wide defaults for lines that don't set their own.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record CreateFlatGrnRequest(
Guid PurchaseOrderId,
Guid WarehouseId,
List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null);
Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
/// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
public record CreateDirectGrnRequest(
Guid WarehouseId,
List<GrnItemRequest> Items,
Guid? SupplierId = null,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
public record GrnItemResponse(
Guid Id,
Guid PurchaseOrderItemId,
Guid? PurchaseOrderItemId,
Guid ItemId,
string Sku,
string ItemName,
int AcceptedQuantity,
int RejectedQuantity);
int RejectedQuantity,
List<GrnBatchLineResponse> Batches,
List<string> SerialNumbers,
decimal UnitCost,
decimal DiscountPercent,
decimal VatPercent,
// Accepted qty x unit cost, before discount.
decimal GrossAmount,
decimal DiscountAmount,
decimal VatAmount,
// Gross - discount + VAT.
decimal LineTotal,
int FreeQuantity,
decimal NbtPercent,
decimal NbtAmount);
public record GrnResponse(
Guid Id,
Guid PurchaseOrderId,
Guid? PurchaseOrderId,
Guid ReceivedByUserId,
Guid WarehouseId,
string WarehouseName,
Guid? SupplierId,
string? SupplierName,
DateTime ReceivedAt,
DateTime CreatedAt,
List<GrnItemResponse> Items);
List<GrnItemResponse> Items,
decimal DiscountPercent,
decimal VatPercent,
// Sum of line gross amounts.
decimal SubTotal,
decimal DiscountAmount,
// SubTotal - DiscountAmount (the base NBT is charged on; VAT is charged on this plus NBT).
decimal TaxableAmount,
decimal VatAmount,
decimal TotalAmount,
decimal NbtPercent,
decimal NbtAmount);
+37
View File
@@ -0,0 +1,37 @@
namespace Distribution.Features.Grn;
public record LineAmounts(decimal Gross, decimal Discount, decimal Nbt, decimal Vat, decimal Total);
/// <summary>
/// GRN costing. Per line: gross = accepted qty x unit cost; discount = gross x disc%;
/// NBT = (gross - discount) x NBT%; VAT = (gross - discount + NBT) x VAT%;
/// total = gross - discount + NBT + VAT. Rejected units are never costed.
/// Each amount is rounded to 2 dp per line so the GRN totals equal the sum of what the lines show.
/// </summary>
public static class GrnPricing
{
public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
public static LineAmounts Line(
int acceptedQty, decimal unitCost, decimal discountPercent, decimal nbtPercent, decimal vatPercent)
{
var gross = Round(acceptedQty * unitCost);
var discount = Round(gross * discountPercent / 100m);
var net = gross - discount;
var nbt = Round(net * nbtPercent / 100m);
var vat = Round((net + nbt) * vatPercent / 100m);
return new LineAmounts(gross, discount, nbt, vat, net + nbt + vat);
}
public static void ValidatePercent(decimal value, string label)
{
if (value < 0 || value > 100)
throw new InvalidOperationException($"{label} must be between 0 and 100.");
}
public static void ValidateCost(decimal value, string label)
{
if (value < 0)
throw new InvalidOperationException($"{label} cannot be negative.");
}
}
+17 -5
View File
@@ -8,13 +8,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.FirstOrDefaultAsync(gr => gr.Id == id);
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.OrderByDescending(gr => gr.ReceivedAt)
.Skip((page - 1) * pageSize)
@@ -29,7 +37,11 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
.OrderByDescending(gr => gr.ReceivedAt)
.ToListAsync();
@@ -43,8 +55,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
await context.GoodsReceiptItems
.Include(i => i.PurchaseOrderItem)
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
.Where(i => i.PurchaseOrderItem!.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem!.FK_ItemId)
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
}
+382 -41
View File
@@ -1,5 +1,6 @@
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -9,15 +10,88 @@ public class GrnService(
IGrnRepository repo,
IPoRepository poRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
Distribution.Features.SysConfig.SysConfigService sysConfig)
{
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
{
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
var item = i.Item ?? i.PurchaseOrderItem!.Item;
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name,
i.AcceptedQuantity, i.RejectedQuantity,
i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(),
i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList(),
i.UnitCost, i.DiscountPercent, i.VatPercent, a.Gross, a.Discount, a.Vat, a.Total, i.FreeQuantity,
i.NbtPercent, a.Nbt);
}
private static GrnResponse Map(Models.GoodsReceipt gr) =>
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
/// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary>
private static int StockQty(GrnItemRequest line) => line.AcceptedQuantity + line.FreeQuantity;
private static GrnResponse Map(Models.GoodsReceipt gr)
{
var items = gr.Items.Where(i => i.IsActive).Select(MapItem).ToList();
var subTotal = items.Sum(i => i.GrossAmount);
var discount = items.Sum(i => i.DiscountAmount);
var vat = items.Sum(i => i.VatAmount);
var nbt = items.Sum(i => i.NbtAmount);
return new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.FK_SupplierId, gr.Supplier?.Name,
gr.ReceivedAt, gr.CreatedAt, items,
gr.DiscountPercent, gr.VatPercent,
subTotal, discount, subTotal - discount, vat, subTotal - discount + nbt + vat,
gr.NbtPercent, nbt);
}
/// <summary>The percentages a receipt is saved with, after the configured tax rules were applied.</summary>
private record GrnHeader(decimal Discount, decimal Vat, decimal Nbt, decimal? RequestedVat, decimal? RequestedNbt);
/// <summary>
/// Validates and resolves the GRN-wide percentages that lines fall back to. A disabled tax is
/// forced to 0 whatever the client sent.
/// </summary>
private static GrnHeader ResolveHeader(GrnTaxSettings tax, decimal? discountPercent, decimal? vatPercent, decimal? nbtPercent)
{
var discount = discountPercent ?? 0m;
var vat = tax.VatMode == TaxMode.Disabled ? 0m : vatPercent ?? 0m;
var nbt = tax.NbtMode == TaxMode.Disabled ? 0m : nbtPercent ?? 0m;
GrnPricing.ValidatePercent(discount, "GRN discount %");
GrnPricing.ValidatePercent(vat, "GRN VAT %");
GrnPricing.ValidatePercent(nbt, "GRN NBT %");
return new GrnHeader(discount, vat, nbt, vatPercent, nbtPercent);
}
/// <summary>
/// A line's rate for one tax: 0 when the tax is disabled; otherwise the line's own value, else the
/// GRN-wide one. A Mandatory tax must be supplied somewhere; an Optional one defaults to 0.
/// </summary>
private static decimal ResolveTax(TaxMode mode, decimal? headerValue, decimal? lineValue, string label)
{
if (mode == TaxMode.Disabled) return 0m;
var value = lineValue ?? headerValue;
if (value is null)
{
if (mode == TaxMode.Mandatory) throw new InvalidOperationException($"{label} is mandatory.");
return 0m;
}
GrnPricing.ValidatePercent(value.Value, label);
return value.Value;
}
/// <summary>Copies a line's resolved cost, discount %, NBT % and VAT % onto the entity after validating them.</summary>
private static void ApplyPricing(Models.GoodsReceiptItem entity, GrnItemRequest line, decimal fallbackUnitCost,
GrnHeader header, GrnTaxSettings tax, string sku)
{
entity.UnitCost = line.UnitCost ?? fallbackUnitCost;
entity.DiscountPercent = line.DiscountPercent ?? header.Discount;
entity.VatPercent = ResolveTax(tax.VatMode, header.RequestedVat, line.VatPercent, $"VAT % for item {sku}");
entity.NbtPercent = ResolveTax(tax.NbtMode, header.RequestedNbt, line.NbtPercent, $"NBT % for item {sku}");
GrnPricing.ValidateCost(entity.UnitCost, $"Unit cost for item {sku}");
GrnPricing.ValidatePercent(entity.DiscountPercent, $"Discount % for item {sku}");
}
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
{
@@ -36,10 +110,61 @@ public class GrnService(
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
/// <summary>
/// Validates that a GRN line's batch/serial payload matches the item's tracking flags. When an
/// item tracks both serial numbers and batches/expiry, serial tracking takes precedence for
/// stock-ledger granularity in this phase — batch/expiry metadata for that combination is not
/// yet linked into the ledger (follow-up work).
/// </summary>
private static void ValidateLineShape(Models.Item item, GrnItemRequest line)
{
if (item.TracksSerialNumbers)
{
if (StockQty(line) > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != StockQty(line)))
throw new InvalidOperationException(
$"Item {item.Sku} tracks serial numbers — expected {StockQty(line)} serial number(s).");
if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count)
throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}.");
}
else if (line.SerialNumbers is { Count: > 0 })
{
throw new InvalidOperationException($"Item {item.Sku} does not track serial numbers.");
}
else if (item.TracksBatches || item.TracksExpiryDate)
{
if (StockQty(line) > 0)
{
if (line.Batches is null || line.Batches.Count == 0)
throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information.");
if (line.Batches.Sum(b => b.Quantity) != StockQty(line))
throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity.");
}
foreach (var b in line.Batches ?? [])
{
if (b.Quantity <= 0)
throw new InvalidOperationException($"Batch quantity for item {item.Sku} must be positive.");
if (item.TracksBatches && string.IsNullOrWhiteSpace(b.BatchNumber))
throw new InvalidOperationException($"Item {item.Sku} requires a batch number.");
if (!item.TracksBatches && !string.IsNullOrWhiteSpace(b.BatchNumber))
throw new InvalidOperationException($"Item {item.Sku} does not track batch numbers.");
if (item.TracksExpiryDate && b.ExpiryDate is null)
throw new InvalidOperationException($"Item {item.Sku} requires an expiry date.");
}
}
else if (line.Batches is { Count: > 0 })
{
throw new InvalidOperationException($"Item {item.Sku} does not track batches or expiry dates.");
}
}
/// <summary>
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
/// StockMovement for AcceptedQuantity + FreeQuantity (RejectedQuantity never touches stock; free
/// units don't count against the PO's ordered quantity), creates
/// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
/// PartiallyReceived/Received. All in one unit of work.
/// </summary>
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{
@@ -56,37 +181,63 @@ public class GrnService(
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item.");
var tax = await sysConfig.GetGrnTaxSettingsAsync();
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
var goodsReceipt = new Models.GoodsReceipt
{
FK_PurchaseOrderId = po.Id,
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now
ReceivedAt = DateTime.Now,
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
foreach (var line in request.Items)
{
if (line.PurchaseOrderItemId is null)
throw new InvalidOperationException("Each line of a PO receipt needs a PurchaseOrderItemId.");
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
if (poItem.IsCancelled)
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
throw new InvalidOperationException(
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
ValidateLineShape(poItem.Item, line);
if (poItem.Item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
{
foreach (var serialNumber in line.SerialNumbers)
{
if (await stockRepo.SerialNumberExistsAsync(poItem.FK_ItemId, serialNumber))
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {poItem.Item.Sku}.");
}
}
poItem.ReceivedQuantity += line.AcceptedQuantity;
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
var entity = new Models.GoodsReceiptItem
{
FK_PurchaseOrderItemId = poItem.Id,
AcceptedQuantity = line.AcceptedQuantity,
FreeQuantity = line.FreeQuantity,
RejectedQuantity = line.RejectedQuantity
});
};
ApplyPricing(entity, line, poItem.UnitCost, header, tax, poItem.Item.Sku);
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, poItem.Item));
}
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
@@ -94,48 +245,238 @@ public class GrnService(
? PurchaseOrderStatus.Received
: PurchaseOrderStatus.PartiallyReceived;
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, po, ct);
}
/// <summary>
/// A receipt with no purchase order: lines name the item directly. Stock, serial and batch
/// handling is identical to a PO receipt; there is no PO quantity or status to update.
/// </summary>
public async Task<GrnResponse> CreateDirectAsync(CreateDirectGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item.");
if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true })
throw new InvalidOperationException("Supplier not found.");
var tax = await sysConfig.GetGrnTaxSettingsAsync();
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
var goodsReceipt = new Models.GoodsReceipt
{
FK_PurchaseOrderId = null,
FK_SupplierId = request.SupplierId,
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now,
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
var serialsInRequest = new HashSet<(Guid ItemId, string Serial)>();
foreach (var line in request.Items)
{
if (line.ItemId is null)
throw new InvalidOperationException("Each line of a direct receipt needs an ItemId.");
var item = await itemRepo.GetByIdAsync(line.ItemId.Value);
if (item is null || !item.IsActive)
throw new InvalidOperationException($"Item {line.ItemId} not found.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity + line.FreeQuantity == 0)
throw new InvalidOperationException($"Line for item {item.Sku} has no quantity.");
ValidateLineShape(item, line);
if (item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
{
foreach (var serialNumber in line.SerialNumbers)
{
if (!serialsInRequest.Add((item.Id, serialNumber)))
throw new InvalidOperationException($"Serial number '{serialNumber}' appears on more than one line for item {item.Sku}.");
if (await stockRepo.SerialNumberExistsAsync(item.Id, serialNumber))
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {item.Sku}.");
}
}
var entity = new Models.GoodsReceiptItem
{
FK_ItemId = item.Id,
FreeQuantity = line.FreeQuantity,
AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity
};
ApplyPricing(entity, line, 0m, header, tax, item.Sku);
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, item));
}
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, null, ct);
}
/// <summary>
/// Writes the receipt, its serials/batches, the stock balances and movements, and (for a PO
/// receipt) the PO update in one unit of work. Shared by PO-based and direct receipts.
/// </summary>
private async Task<GrnResponse> PostReceiptAsync(
Models.GoodsReceipt goodsReceipt,
List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)> grLines,
Guid warehouseId,
Guid receivedByUserId,
Models.PurchaseOrder? po,
CancellationToken ct)
{
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(goodsReceipt);
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
foreach (var (entity, line, item) in grLines.Where(x => StockQty(x.Line) > 0))
{
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
if (balance is null)
if (item.TracksSerialNumbers)
{
await stockRepo.AddBalanceAsync(new StockBalance
foreach (var serialNumber in line.SerialNumbers!)
{
FK_ItemId = poItem.FK_ItemId,
LocationId = request.WarehouseId,
LocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity
});
var serial = new ItemSerialNumber
{
FK_ItemId = item.Id,
SerialNumber = serialNumber,
Status = SerialStatus.InStock,
CurrentLocationId = warehouseId,
CurrentLocationType = LocationType.Warehouse,
ReceivedAt = goodsReceipt.ReceivedAt
};
// Warranty is a customer-facing promise: the clock starts when the serial is sold,
// not at receipt. Only snapshot the item's custom period here; WarrantyExpiresAt
// stays null until the sale/delivery flow sets it.
if (item.HasWarranty && item.AllowCustomWarrantyPeriod)
serial.WarrantyPeriodDaysOverride = item.WarrantyPeriodDays;
await stockRepo.AddItemSerialNumberAsync(serial);
entity.Serials.Add(new GoodsReceiptItemSerial { FK_GoodsReceiptItemId = entity.Id, ItemSerialNumber = serial });
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
FK_ItemSerialNumberId = serial.Id,
Quantity = 1
});
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
FK_ItemSerialNumberId = serial.Id,
Quantity = 1,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
else if (item.TracksBatches || item.TracksExpiryDate)
{
foreach (var b in line.Batches!)
{
var batch = !string.IsNullOrWhiteSpace(b.BatchNumber)
? await stockRepo.FindBatchAsync(item.Id, b.BatchNumber)
: null;
if (batch is null)
{
batch = new ItemBatch
{
FK_ItemId = item.Id,
BatchNumber = b.BatchNumber,
ExpiryDate = b.ExpiryDate
};
await stockRepo.AddItemBatchAsync(batch);
}
entity.Batches.Add(new GoodsReceiptItemBatch { FK_GoodsReceiptItemId = entity.Id, ItemBatch = batch, Quantity = b.Quantity });
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId, batch.Id, null);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
FK_ItemBatchId = batch.Id,
Quantity = b.Quantity
});
}
else
{
balance.Quantity += b.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
FK_ItemBatchId = batch.Id,
Quantity = b.Quantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
else
{
balance.Quantity += line.AcceptedQuantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
Quantity = StockQty(line)
});
}
else
{
balance.Quantity += StockQty(line);
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = poItem.FK_ItemId,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.WarehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = StockQty(line),
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
poRepo.Update(po);
if (po is not null) poRepo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -6,8 +6,9 @@ public interface IInvoiceRepository
{
Task<Models.Invoice?> GetByIdAsync(Guid id);
Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId);
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status);
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
Task<List<Models.Invoice>> GetOutstandingAsync();
Task AddAsync(Models.Invoice invoice);
void Update(Models.Invoice invoice);
}
@@ -1,11 +1,12 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Distribution.Models;
namespace Distribution.Features.Invoice;
[ApiController]
[Route("api/invoices")]
public class InvoiceController(InvoiceService service) : ControllerBase
public class InvoiceController(InvoiceService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
@@ -13,10 +14,18 @@ public class InvoiceController(InvoiceService service) : ControllerBase
[FromQuery] Guid? routeId,
[FromQuery] Guid? shopId,
[FromQuery] InvoiceStatus? status,
[FromQuery] PaymentStatus? paymentStatus,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
var result = await service.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
return Ok(result);
}
[HttpGet("outstanding-summary")]
public async Task<IActionResult> GetOutstandingSummary()
{
var result = await service.GetOutstandingSummaryAsync();
return Ok(result);
}
@@ -26,4 +35,36 @@ public class InvoiceController(InvoiceService service) : ControllerBase
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request?.PrintedByUserId;
if (userId is null) return BadRequest(new { error = "PrintedByUserId is required while auth is unavailable" });
try
{
var result = await service.PrintAsync(id, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/finalize")]
public async Task<IActionResult> Finalize(Guid id, [FromBody] FinalizeInvoiceRequest? request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request?.FinalizedByUserId;
if (userId is null) return BadRequest(new { error = "FinalizedByUserId is required while auth is unavailable" });
try
{
var result = await service.FinalizeAsync(id, request ?? new FinalizeInvoiceRequest(), userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
+4 -1
View File
@@ -4,7 +4,8 @@ namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId);
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment> Adjustments);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
public record InvoiceResponse(
Guid Id,
@@ -20,3 +21,5 @@ public record InvoiceResponse(
decimal TotalAmount,
bool IsActive,
DateTime CreatedAt);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
@@ -9,6 +9,7 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
.Include(i => i.Payments.Where(p => p.IsActive))
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
@@ -17,27 +18,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
.Include(i => i.ShopPO)
.FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive);
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize) =>
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Where(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status))
&& (status == null || i.Status == status)
&& (paymentStatus == null || i.PaymentStatus == paymentStatus))
.OrderByDescending(i => i.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status) =>
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.CountAsync(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status));
&& (status == null || i.Status == status)
&& (paymentStatus == null || i.PaymentStatus == paymentStatus));
public async Task<List<Models.Invoice>> GetOutstandingAsync() =>
await context.Invoices
.Include(i => i.Payments.Where(p => p.IsActive))
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
.ToListAsync();
public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice);
@@ -24,10 +24,75 @@ public class InvoiceService(
return invoice is null ? null : ToResponse(invoice);
}
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize)
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, status);
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
var total = await repo.GetCountAsync(agentId, routeId, shopId, status, paymentStatus);
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<OutstandingPaymentsResponse> GetOutstandingSummaryAsync()
{
var invoices = await repo.GetOutstandingAsync();
var count = invoices.Count;
var outstanding = invoices.Sum(i => i.TotalAmount - i.Payments.Sum(p => p.Amount));
return new OutstandingPaymentsResponse(count, outstanding);
}
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
invoice.Status = InvoiceStatus.Printed;
invoice.PrintedAt = DateTime.Now;
invoice.PrintedBy = printedBy;
await uow.BeginAsync(ct);
try { repo.Update(invoice); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice);
}
public async Task<InvoiceResponse> FinalizeAsync(Guid id, FinalizeInvoiceRequest request, Guid finalizedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
if (invoice.Status != InvoiceStatus.Printed)
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
var adjustments = request.Adjustments ?? [];
if (adjustments.Count > 0)
{
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
decimal total = 0;
foreach (var item in poItems)
{
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
if (billedQuantity < 0 || billedQuantity > item.Quantity)
throw new InvalidOperationException(
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
total += billedQuantity * item.UnitPrice;
}
invoice.TotalAmount = total;
}
invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now;
invoice.FinalizedBy = finalizedBy;
await uow.BeginAsync(ct);
try { repo.Update(invoice); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice);
}
}
+30 -2
View File
@@ -11,7 +11,14 @@ public record CreateItemRequest(
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice,
List<UomConversionRequest>? Conversions);
List<UomConversionRequest>? Conversions,
decimal? ReorderLevel = null,
bool TracksSerialNumbers = false,
bool TracksBatches = false,
bool TracksExpiryDate = false,
bool HasWarranty = false,
int? WarrantyPeriodDays = null,
bool AllowCustomWarrantyPeriod = false);
public record UpdateItemRequest(
string Name,
@@ -21,7 +28,14 @@ public record UpdateItemRequest(
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice,
bool IsActive = true);
bool IsActive = true,
decimal? ReorderLevel = null,
bool TracksSerialNumbers = false,
bool TracksBatches = false,
bool TracksExpiryDate = false,
bool HasWarranty = false,
int? WarrantyPeriodDays = null,
bool AllowCustomWarrantyPeriod = false);
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
@@ -40,6 +54,13 @@ public record ItemSummaryResponse(
Guid BaseUomId,
string BaseUomName,
decimal? SalePrice,
decimal? ReorderLevel,
bool TracksSerialNumbers,
bool TracksBatches,
bool TracksExpiryDate,
bool HasWarranty,
int? WarrantyPeriodDays,
bool AllowCustomWarrantyPeriod,
Models.SyncSource SyncSource,
bool IsActive,
DateTime CreatedAt);
@@ -58,6 +79,13 @@ public record ItemDetailResponse(
Guid BaseUomId,
string BaseUomName,
decimal? SalePrice,
decimal? ReorderLevel,
bool TracksSerialNumbers,
bool TracksBatches,
bool TracksExpiryDate,
bool HasWarranty,
int? WarrantyPeriodDays,
bool AllowCustomWarrantyPeriod,
Models.SyncSource SyncSource,
bool IsActive,
DateTime CreatedAt,
+61 -3
View File
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
namespace Distribution.Features.Item;
public class ItemService(IItemRepository repo, IUnitOfWork uow)
public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysConfigService sysConfig)
{
private static UomConversionResponse MapConversion(Models.UomConversion c) =>
new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor);
@@ -13,7 +13,10 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt);
i.SalePrice, i.ReorderLevel,
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
i.SyncSource, i.IsActive, i.CreatedAt);
private static ItemDetailResponse MapDetail(Models.Item i) =>
new(i.Id, i.Sku, i.Name, i.Description,
@@ -21,9 +24,44 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt,
i.SalePrice, i.ReorderLevel,
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
i.SyncSource, i.IsActive, i.CreatedAt,
i.Conversions.Select(MapConversion).ToList());
/// <summary>
/// Rejects per-item tracking flags that are off system-wide, and warranty-period values that
/// don't make sense given HasWarranty/AllowCustomWarrantyPeriod.
/// </summary>
private async Task ValidateTrackingFlagsAsync(bool tracksSerialNumbers, bool tracksBatches,
bool tracksExpiryDate, bool hasWarranty, int? warrantyPeriodDays, bool allowCustomWarrantyPeriod)
{
if (tracksSerialNumbers && !await sysConfig.GetSerialNumberTrackingEnabledAsync())
throw new InvalidOperationException("Serial number tracking is disabled system-wide.");
if (tracksBatches && !await sysConfig.GetBatchTrackingEnabledAsync())
throw new InvalidOperationException("Batch tracking is disabled system-wide.");
if (tracksExpiryDate && !await sysConfig.GetExpiryDateTrackingEnabledAsync())
throw new InvalidOperationException("Expiry date tracking is disabled system-wide.");
if (hasWarranty && !await sysConfig.GetWarrantyTrackingEnabledAsync())
throw new InvalidOperationException("Warranty tracking is disabled system-wide.");
if (allowCustomWarrantyPeriod && !await sysConfig.GetCustomWarrantyOverrideEnabledAsync())
throw new InvalidOperationException("Custom warranty period override is disabled system-wide.");
if (!hasWarranty && (warrantyPeriodDays is not null || allowCustomWarrantyPeriod))
throw new InvalidOperationException("Warranty period/override can only be set when HasWarranty is true.");
if (warrantyPeriodDays is not null && !allowCustomWarrantyPeriod)
throw new InvalidOperationException("WarrantyPeriodDays can only be set when AllowCustomWarrantyPeriod is true.");
if (warrantyPeriodDays is < 0)
throw new InvalidOperationException("WarrantyPeriodDays cannot be negative.");
}
public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
(await repo.GetAllAsync(categoryId, subCategoryId, brandId))
.Select(MapSummary)
@@ -41,6 +79,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
if (await repo.SkuExistsAsync(request.Sku))
throw new InvalidOperationException($"SKU '{request.Sku}' already exists.");
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
var item = new Models.Item
{
Sku = request.Sku,
@@ -51,6 +92,13 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
FK_BrandId = request.BrandId,
FK_BaseUomId = request.BaseUomId,
SalePrice = request.SalePrice,
ReorderLevel = request.ReorderLevel,
TracksSerialNumbers = request.TracksSerialNumbers,
TracksBatches = request.TracksBatches,
TracksExpiryDate = request.TracksExpiryDate,
HasWarranty = request.HasWarranty,
WarrantyPeriodDays = request.WarrantyPeriodDays,
AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod,
SyncSource = Models.SyncSource.Manual
};
@@ -77,6 +125,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
if (item is null) return null;
if (item.SyncSource == Models.SyncSource.Erp) return null;
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
item.Name = request.Name;
item.Description = request.Description;
item.FK_CategoryId = request.CategoryId;
@@ -84,6 +135,13 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
item.FK_BrandId = request.BrandId;
item.FK_BaseUomId = request.BaseUomId;
item.SalePrice = request.SalePrice;
item.ReorderLevel = request.ReorderLevel;
item.TracksSerialNumbers = request.TracksSerialNumbers;
item.TracksBatches = request.TracksBatches;
item.TracksExpiryDate = request.TracksExpiryDate;
item.HasWarranty = request.HasWarranty;
item.WarrantyPeriodDays = request.WarrantyPeriodDays;
item.AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod;
item.IsActive = request.IsActive;
await uow.BeginAsync(ct);
@@ -6,10 +6,12 @@ public interface ILoadingSheetRepository
{
Task<LoadingSheet?> GetByIdAsync(Guid id);
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId);
Task AddAsync(LoadingSheet sheet);
void Update(LoadingSheet sheet);
void RemoveItems(IEnumerable<LoadingSheetItem> items);
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
Task<List<Guid>> GetShopPOIdsByItemIdsAsync(IEnumerable<Guid> shopPOItemIds);
Task ReplaceOrdersAsync(Guid loadingSheetId, List<Guid> shopPOIds);
}
@@ -1,25 +1,47 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.LoadingEvent;
[ApiController]
[Route("api/loading-sheets")]
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
public class LoadingSheetController(
LoadingSheetService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
//[Authorize("loading-sheet.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] LoadingSheetStatus? status,
[FromQuery] Guid? routeId,
[FromQuery] Guid? regionId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(rideId, status, page, pageSize);
if (routeId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRouteScopeAsync(userId.Value, routeId.Value))
return StatusCode(403);
}
if (regionId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, regionId.Value))
return StatusCode(403);
}
var result = await service.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
[Authorize("loading-sheet.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -27,12 +49,16 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.PopulatedByUserId;
if (userId is null) return BadRequest(new { error = "PopulatedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateAsync(request, userId, ct);
var result = await service.CreateAsync(request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -40,6 +66,7 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPut("{id:guid}/items")]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
{
try
@@ -52,12 +79,16 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> Confirm(Guid id, ConfirmLoadingSheetRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
try
{
var result = await service.ConfirmAsync(id, userId, ct);
var result = await service.ConfirmAsync(id, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -21,11 +21,16 @@ public record LoadingSheetItemResponse(
Guid FK_WarehouseId,
string WarehouseName,
decimal QuantityDispatched,
Guid? FK_ShopPOItemId);
Guid? FK_ShopPOItemId,
Guid? ShopPOId = null,
Guid? StoreId = null,
string? StoreName = null);
public record LoadingSheetOrderResponse(Guid ShopPOId);
public record CreateLoadingSheetRequest(Guid FK_RideId);
public record CreateLoadingSheetRequest(Guid FK_RideId, Guid? PopulatedByUserId = null);
public record SetItemsRequest(List<SetItemInput> Items);
public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
public record ConfirmLoadingSheetRequest(Guid? ConfirmedByUserId = null);
@@ -10,6 +10,7 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
await context.LoadingSheets
.Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Items).ThenInclude(i => i.ShopPOItem).ThenInclude(p => p!.ShopPO).ThenInclude(p => p.Store)
.Include(s => s.Orders)
.FirstOrDefaultAsync(s => s.Id == id);
@@ -17,24 +18,29 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
await context.LoadingSheets
.FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize) =>
await context.LoadingSheets
.Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Items).ThenInclude(i => i.ShopPOItem).ThenInclude(p => p!.ShopPO).ThenInclude(p => p.Store)
.Include(s => s.Orders)
.Where(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status))
(status == null || s.Status == status) &&
(routeId == null || s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)) &&
(regionId == null || s.Orders.Any(o => o.ShopPO.Store.Route.FK_RegionId == regionId)))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status) =>
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId) =>
await context.LoadingSheets
.CountAsync(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status));
(status == null || s.Status == status) &&
(routeId == null || s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)) &&
(regionId == null || s.Orders.Any(o => o.ShopPO.Store.Route.FK_RegionId == regionId)));
public async Task AddAsync(LoadingSheet sheet) =>
await context.LoadingSheets.AddAsync(sheet);
@@ -47,4 +53,25 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
public async Task AddItemsAsync(IEnumerable<LoadingSheetItem> items) =>
await context.LoadingSheetItems.AddRangeAsync(items);
public async Task<List<Guid>> GetShopPOIdsByItemIdsAsync(IEnumerable<Guid> shopPOItemIds) =>
await context.ShopPOItems
.Where(i => shopPOItemIds.Contains(i.Id))
.Select(i => i.FK_ShopPOId)
.Distinct()
.ToListAsync();
public async Task ReplaceOrdersAsync(Guid loadingSheetId, List<Guid> shopPOIds)
{
var current = await context.LoadingSheetOrders
.Where(o => o.FK_LoadingSheetId == loadingSheetId)
.ToListAsync();
context.LoadingSheetOrders.RemoveRange(current);
await context.LoadingSheetOrders.AddRangeAsync(shopPOIds.Select(shopPOId => new LoadingSheetOrder
{
FK_LoadingSheetId = loadingSheetId,
FK_ShopPOId = shopPOId
}));
}
}
@@ -8,13 +8,13 @@ namespace Distribution.Features.LoadingEvent;
public class LoadingSheetService(
ILoadingSheetRepository repo,
IRideRepository rideRepo,
IStockRepository stockRepo,
StockMover mover,
IUnitOfWork uow)
{
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize)
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize)
{
var total = await repo.GetCountAsync(rideId, status);
var items = await repo.GetAllAsync(rideId, status, page, pageSize);
var total = await repo.GetCountAsync(rideId, status, routeId, regionId);
var items = await repo.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
return new PagedResponse<LoadingSheetResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
@@ -64,18 +64,54 @@ public class LoadingSheetService(
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
// The request is the full desired item list: a row matching (item, warehouse,
// shop PO item) gets its quantity updated in place, a new combination is added,
// and rows for combinations not present in the request are removed.
await uow.BeginAsync(ct);
try
{
repo.RemoveItems(sheet.Items);
await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem
var newItems = new List<LoadingSheetItem>();
var keptItems = new List<LoadingSheetItem>();
foreach (var input in request.Items)
{
FK_LoadingSheetId = sheet.Id,
FK_ItemId = i.FK_ItemId,
FK_WarehouseId = i.FK_WarehouseId,
FK_ShopPOItemId = i.FK_ShopPOItemId,
QuantityDispatched = i.Quantity
}));
var existing = sheet.Items.FirstOrDefault(i =>
i.FK_ItemId == input.FK_ItemId &&
i.FK_WarehouseId == input.FK_WarehouseId &&
i.FK_ShopPOItemId == input.FK_ShopPOItemId);
if (existing is not null)
{
existing.QuantityDispatched = input.Quantity;
keptItems.Add(existing);
}
else
newItems.Add(new LoadingSheetItem
{
FK_LoadingSheetId = sheet.Id,
FK_ItemId = input.FK_ItemId,
FK_WarehouseId = input.FK_WarehouseId,
FK_ShopPOItemId = input.FK_ShopPOItemId,
QuantityDispatched = input.Quantity
});
}
var removedItems = sheet.Items.Where(i => !keptItems.Contains(i)).ToList();
if (removedItems.Count > 0)
repo.RemoveItems(removedItems);
if (newItems.Count > 0)
await repo.AddItemsAsync(newItems);
var shopPOItemIds = keptItems.Select(i => i.FK_ShopPOItemId)
.Concat(newItems.Select(i => i.FK_ShopPOItemId))
.Where(x => x.HasValue)
.Select(x => x!.Value)
.Distinct()
.ToList();
var shopPOIds = shopPOItemIds.Count > 0
? await repo.GetShopPOIdsByItemIdsAsync(shopPOItemIds)
: new List<Guid>();
await repo.ReplaceOrdersAsync(sheet.Id, shopPOIds);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -120,46 +156,12 @@ public class LoadingSheetService(
{
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
{
var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {item.FK_ItemId}");
var label = $"{item.Item?.Name ?? item.FK_ItemId.ToString()} (warehouse {item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()})";
if (warehouseBalance.Quantity < item.QuantityDispatched)
throw new InvalidOperationException($"Insufficient warehouse stock for item {item.FK_ItemId}");
warehouseBalance.Quantity -= item.QuantityDispatched;
warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
if (vehicleBalance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.FK_ItemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = item.QuantityDispatched
});
}
else
{
vehicleBalance.Quantity += item.QuantityDispatched;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = item.FK_WarehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = item.QuantityDispatched,
Type = MovementType.Load,
ReferenceId = sheetId,
CreatedBy = userId
});
await mover.MoveAsync(item.FK_ItemId, label,
item.FK_WarehouseId, LocationType.Warehouse,
vehicleId, LocationType.Vehicle,
item.QuantityDispatched, MovementType.Load, sheetId, userId);
}
}
@@ -169,6 +171,9 @@ public class LoadingSheetService(
s.Items.Select(i => new LoadingSheetItemResponse(
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.FK_WarehouseId, i.Warehouse?.Name ?? string.Empty,
i.QuantityDispatched, i.FK_ShopPOItemId)).ToList(),
i.QuantityDispatched, i.FK_ShopPOItemId,
i.ShopPOItem?.FK_ShopPOId,
i.ShopPOItem?.ShopPO?.FK_StoreId,
i.ShopPOItem?.ShopPO?.Store?.StoreName)).ToList(),
s.Orders.Select(o => new LoadingSheetOrderResponse(o.FK_ShopPOId)).ToList());
}
@@ -1,16 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PriceList;
[ApiController]
[Route("api/price-lists")]
public class PriceListController(PriceListService service) : ControllerBase
public class PriceListController(
PriceListService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[Authorize("price-list.view")]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId)
{
if (regionId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, regionId.Value))
return StatusCode(403);
}
return Ok(await service.GetAllAsync(regionId));
}
[HttpGet("resolve")]
[Authorize("price-list.view")]
public async Task<IActionResult> Resolve([FromQuery] Guid itemId, [FromQuery] Guid storeId, [FromQuery] DateTime saleDate)
{
var result = await service.ResolveByStoreAsync(itemId, storeId, saleDate);
@@ -18,6 +33,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpGet("{id:guid}")]
[Authorize("price-list.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -25,6 +41,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpPost]
[Authorize("price-list.manage")]
public async Task<IActionResult> Create(CreatePriceListRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -32,6 +49,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> Update(Guid id, UpdatePriceListRequest request, CancellationToken ct)
{
try
@@ -46,6 +64,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -53,6 +72,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpPut("{id:guid}/items/{sku}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> UpsertItem(Guid id, string sku, UpsertPriceListItemRequest request, CancellationToken ct)
{
try
@@ -67,6 +87,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpDelete("{id:guid}/items/{sku}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> RemoveItem(Guid id, string sku, CancellationToken ct)
{
try
@@ -0,0 +1,18 @@
using Distribution.Models;
namespace Distribution.Features.ProductReport;
public record SalesAggregate(Guid ItemId, string Sku, string Name, decimal? CostPrice,
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled, decimal SalesValue);
public record ReturnAggregate(Guid ItemId, string Sku, string Name, ReturnReasonCode Reason,
decimal Qty, decimal Value);
public interface IProductReportRepository
{
Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize);
Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize);
Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f);
Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds);
Task<decimal> GetTotalSalesAsync(ProductReportFilter f);
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ProductReport;
[ApiController]
[Route("api/reports/products")]
public class ProductReportController(ProductReportService service) : ControllerBase
{
private IActionResult? Check(ProductReportFilter f, int page, int pageSize)
{
var error = ProductReportService.Validate(f, page, pageSize);
return error is null ? null : BadRequest(new { message = error });
}
[HttpGet("stock")]
public async Task<IActionResult> Stock(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? warehouseId, [FromQuery] bool activeOnly = true, [FromQuery] bool lowStockOnly = false,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, warehouseId, null, null, null,
null, null, activeOnly, lowStockOnly, search);
return Check(f, page, pageSize) ?? Ok(await service.GetStockAsync(f, page, pageSize));
}
[HttpGet("sales")]
public async Task<IActionResult> Sales(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
from, to, false, false, search);
return Check(f, page, pageSize) ?? Ok(await service.GetSalesAsync(f, page, pageSize));
}
[HttpGet("returns")]
public async Task<IActionResult> Returns(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
from, to, false, false, search);
return Check(f, page, pageSize) ?? Ok(await service.GetReturnsAsync(f, page, pageSize));
}
[HttpGet("summary")]
public async Task<IActionResult> Summary(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? warehouseId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var f = new ProductReportFilter(brandId, categoryId, null, warehouseId, null, null, null,
from, to, true, false, null);
return Check(f, 1, 1) ?? Ok(await service.GetSummaryAsync(f));
}
}
@@ -0,0 +1,26 @@
using Distribution.Models;
namespace Distribution.Features.ProductReport;
public record ProductStockRow(
Guid ItemId, string Sku, string Name, string? Brand, string Category, string? SubCategory, string Uom,
decimal? SalePrice, decimal? CostPrice, decimal? ReorderLevel,
decimal WarehouseQty, decimal VehicleQty, decimal TotalQty, decimal StockValue,
bool IsLowStock, bool IsActive);
public record ProductSalesRow(
Guid ItemId, string Sku, string Name,
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled,
decimal SalesValue, decimal? CostValue, decimal? Margin, decimal? MarginPercent);
public record ProductReturnRow(
Guid ItemId, string Sku, string Name,
decimal ReturnedQty, decimal ReturnedValue, decimal SoldQty, decimal? ReturnPercent,
Dictionary<ReturnReasonCode, decimal> QtyByReason);
public record ProductReportSummary(int ItemCount, decimal TotalQty, decimal TotalValue, decimal TotalSales);
public record ProductReportFilter(
Guid? BrandId, Guid? CategoryId, Guid? SubCategoryId, Guid? WarehouseId,
Guid? RegionId, Guid? RouteId, Guid? StoreId,
DateTime? From, DateTime? To, bool ActiveOnly, bool LowStockOnly, string? Search);
@@ -0,0 +1,156 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ProductReport;
public class ProductReportRepository(AppDBContext db) : IProductReportRepository
{
public async Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize)
{
var balances = db.StockBalances.AsNoTracking().AsQueryable();
if (f.WarehouseId.HasValue)
balances = balances.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
var items = db.Items.AsNoTracking().AsQueryable();
if (f.ActiveOnly) items = items.Where(i => i.IsActive);
if (f.BrandId.HasValue) items = items.Where(i => i.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) items = items.Where(i => i.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) items = items.Where(i => i.FK_SubCategoryId == f.SubCategoryId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
items = items.Where(i => i.Name.Contains(s) || i.Sku.Contains(s));
}
var withQty = items.Select(i => new
{
Item = i,
Wh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Warehouse)
.Sum(b => b.Quantity),
Veh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Vehicle)
.Sum(b => b.Quantity),
});
if (f.LowStockOnly)
withQty = withQty.Where(x => x.Item.ReorderLevel != null && x.Wh <= x.Item.ReorderLevel);
var total = await withQty.CountAsync();
var page_ = await withQty
.OrderBy(x => x.Item.Name)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new
{
x.Item.Id, x.Item.Sku, x.Item.Name,
Brand = x.Item.Brand != null ? x.Item.Brand.Name : null,
Category = x.Item.Category.Name,
SubCategory = x.Item.SubCategory != null ? x.Item.SubCategory.Name : null,
Uom = x.Item.BaseUom.Name,
x.Item.SalePrice, x.Item.CostPrice, x.Item.ReorderLevel, x.Item.IsActive,
x.Wh, x.Veh,
})
.ToListAsync();
var rows = page_.Select(r => new ProductStockRow(
r.Id, r.Sku, r.Name, r.Brand, r.Category, r.SubCategory, r.Uom,
r.SalePrice, r.CostPrice, r.ReorderLevel,
r.Wh, r.Veh, r.Wh + r.Veh, (r.Wh + r.Veh) * (r.CostPrice ?? 0m),
r.ReorderLevel != null && r.Wh <= r.ReorderLevel,
r.IsActive)).ToList();
return (rows, total);
}
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
{
var lines =
from li in db.ShopPOItems.AsNoTracking()
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
where inv.Status == InvoiceStatus.Finalized
&& li.ShopPO.Status != ShopPOStatus.Cancelled
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
select li;
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
lines = lines.Where(l => l.Item.Name.Contains(s) || l.Item.Sku.Contains(s));
}
return lines;
}
public async Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
{
var query = SoldLines(f)
.GroupBy(l => new { l.FK_ItemId, l.Item.Sku, l.Item.Name, l.Item.CostPrice })
.Select(g => new
{
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
});
var total = await query.CountAsync();
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
// Distinct stores per item is counted separately: MySQL can't run the correlated
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
var ids = page_.Select(r => r.FK_ItemId).ToList();
var storePairs = await SoldLines(f)
.Where(l => ids.Contains(l.FK_ItemId))
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
.Distinct()
.ToListAsync();
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
return (rows, total);
}
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
{
var ids = itemIds.ToList();
return await SoldLines(f with { Search = null })
.Where(l => ids.Contains(l.FK_ItemId))
.GroupBy(l => l.FK_ItemId)
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
.ToDictionaryAsync(x => x.Key, x => x.Qty);
}
public Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f)
{
var q = db.SaleReturnItems.AsNoTracking().AsQueryable();
if (f.From.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt >= f.From.Value);
if (f.To.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt < f.To.Value.Date.AddDays(1));
if (f.BrandId.HasValue) q = q.Where(r => r.Item.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) q = q.Where(r => r.Item.FK_SubCategoryId == f.SubCategoryId.Value);
if (f.StoreId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.FK_StoreId == f.StoreId.Value);
if (f.RouteId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.FK_RouteId == f.RouteId.Value);
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
q = q.Where(r => r.Item.Name.Contains(s) || r.Item.Sku.Contains(s));
}
return q.GroupBy(r => new { r.FK_ItemId, r.Item.Sku, r.Item.Name, r.ReasonCode })
.Select(g => new ReturnAggregate(g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.ReasonCode,
g.Sum(x => x.Quantity), g.Sum(x => x.Quantity * x.UnitPrice)))
.ToListAsync();
}
}
@@ -0,0 +1,72 @@
using Distribution.Features.Stock;
namespace Distribution.Features.ProductReport;
public class ProductReportService(IProductReportRepository repo)
{
public const int MaxRangeDays = 366;
public static string? Validate(ProductReportFilter f, int page, int pageSize)
{
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
if (f.From.HasValue && f.To.HasValue)
{
if (f.To < f.From) return "'to' must not be before 'from'.";
if ((f.To.Value - f.From.Value).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
}
return null;
}
public async Task<PagedResponse<ProductStockRow>> GetStockAsync(ProductReportFilter f, int page, int pageSize)
{
var (rows, total) = await repo.GetStockAsync(f, page, pageSize);
return new PagedResponse<ProductStockRow>(page, pageSize, total, rows);
}
public async Task<PagedResponse<ProductSalesRow>> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
{
var (rows, total) = await repo.GetSalesAsync(f, page, pageSize);
var mapped = rows.Select(r =>
{
decimal? cost = r.CostPrice.HasValue ? r.CostPrice.Value * r.QtyFulfilled : null;
decimal? margin = cost.HasValue ? r.SalesValue - cost.Value : null;
decimal? pct = margin.HasValue && r.SalesValue != 0 ? Math.Round(margin.Value / r.SalesValue * 100, 2) : null;
return new ProductSalesRow(r.ItemId, r.Sku, r.Name, r.Orders, r.Stores, r.QtyOrdered, r.QtyFulfilled,
r.SalesValue, cost, margin, pct);
}).ToList();
return new PagedResponse<ProductSalesRow>(page, pageSize, total, mapped);
}
public async Task<PagedResponse<ProductReturnRow>> GetReturnsAsync(ProductReportFilter f, int page, int pageSize)
{
var agg = await repo.GetReturnsAsync(f);
var grouped = agg.GroupBy(a => new { a.ItemId, a.Sku, a.Name })
.Select(g => new
{
g.Key,
Qty = g.Sum(x => x.Qty),
Value = g.Sum(x => x.Value),
ByReason = g.ToDictionary(x => x.Reason, x => x.Qty),
})
.OrderByDescending(x => x.Value).ThenBy(x => x.Key.Name)
.ToList();
var pageRows = grouped.Skip((page - 1) * pageSize).Take(pageSize).ToList();
var sold = await repo.GetSoldQtyAsync(f, pageRows.Select(r => r.Key.ItemId));
var rows = pageRows.Select(r =>
{
var soldQty = sold.GetValueOrDefault(r.Key.ItemId);
decimal? pct = soldQty > 0 ? Math.Round(r.Qty / soldQty * 100, 2) : null;
return new ProductReturnRow(r.Key.ItemId, r.Key.Sku, r.Key.Name, r.Qty, r.Value, soldQty, pct, r.ByReason);
}).ToList();
return new PagedResponse<ProductReturnRow>(page, pageSize, grouped.Count, rows);
}
public async Task<ProductReportSummary> GetSummaryAsync(ProductReportFilter f)
{
var (rows, total) = await repo.GetStockAsync(f, 1, int.MaxValue);
var totalSales = await repo.GetTotalSalesAsync(f);
return new ProductReportSummary(total, rows.Sum(r => r.TotalQty), rows.Sum(r => r.StockValue), totalSales);
}
}
@@ -1,5 +1,6 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Po;
@@ -9,10 +10,12 @@ namespace Distribution.Features.Po;
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) =>
Ok(await service.GetAllAsync(supplierId, status));
[HttpGet("{id:guid}")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -20,6 +23,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Create(CreatePoRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -35,6 +39,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPut("{id:guid}")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Update(Guid id, UpdatePoRequest request, CancellationToken ct)
{
try
@@ -46,6 +51,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/close")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Close(Guid id, CancellationToken ct)
{
try
@@ -57,6 +63,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/cancel")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Cancel(Guid id, CancelPoRequest request, CancellationToken ct)
{
try
@@ -68,6 +75,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/items/{itemId:guid}/cancel")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> CancelLine(Guid id, Guid itemId, CancelPoLineRequest request, CancellationToken ct)
{
try
@@ -79,6 +87,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/match-invoice")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> MatchInvoice(Guid id, [FromQuery] Guid? invoiceId, [FromQuery] Guid? grnId)
{
var result = await service.MatchInvoiceToPoAsync(id, invoiceId, grnId);
@@ -86,6 +95,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/resolve-variance")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> ResolveVariance(Guid id, ResolveInvoiceVarianceRequest request, CancellationToken ct)
{
try
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PurchaseReturn;
@@ -7,6 +8,7 @@ namespace Distribution.Features.PurchaseReturn;
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/purchase-returns/{id:guid}")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -14,10 +16,12 @@ public class PurchaseReturnController(PurchaseReturnService service, ICurrentUse
}
[HttpGet("api/purchase-orders/{id:guid}/returns")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/returns")]
[Authorize("purchase-return.manage")]
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -10,7 +10,7 @@ public class PurchaseReturnService(
IPurchaseReturnRepository repo,
IPoRepository poRepo,
IGrnRepository goodsReceiptRepo,
IStockRepository stockRepo,
StockMover mover,
IUnitOfWork uow)
{
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
@@ -89,29 +89,12 @@ public class PurchaseReturnService(
foreach (var line in purchaseReturn.Items)
{
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId)
?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse.");
var itemName = po.Items.FirstOrDefault(i => i.FK_ItemId == line.FK_ItemId)?.Item?.Name ?? line.FK_ItemId.ToString();
if (balance.Quantity < line.Quantity)
throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return.");
balance.Quantity -= line.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = line.FK_ItemId,
FromLocationId = warehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = StockService.ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = line.Quantity,
Type = MovementType.PurchaseReturn,
ReasonCode = request.Reason,
ReferenceId = purchaseReturn.Id,
CreatedBy = createdByUserId
});
await mover.MoveAsync(line.FK_ItemId, itemName,
warehouseId, LocationType.Warehouse,
StockService.ExternalLocationId, LocationType.External,
line.Quantity, MovementType.PurchaseReturn, purchaseReturn.Id, createdByUserId, request.Reason);
}
await uow.CommitAsync(ct);
@@ -4,6 +4,14 @@ public interface IRegionRepository
{
Task<Models.Region?> GetByIdAsync(Guid id);
Task<List<Models.Region>> GetAllAsync();
Task<List<string>> GetAllCodesAsync();
Task<bool> CodeExistsAsync(string code, Guid? excludeId = null);
Task<Models.User?> GetUserWithRoleAsync(Guid userId);
Task<List<Models.UserRegionScope>> GetManagerScopesAsync(Guid regionId);
Task<Models.UserRegionScope?> GetRegionScopeAsync(Guid userId, Guid regionId);
Task AddRegionScopeAsync(Models.UserRegionScope scope);
void RemoveRegionScopes(IEnumerable<Models.UserRegionScope> scopes);
Task AddAsync(Models.Region entity);
void Update(Models.Region entity);
void Remove(Models.Region entity);
@@ -1,23 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Region;
[ApiController]
[Route("api/regions")]
public class RegionController(RegionService service) : ControllerBase
public class RegionController(
RegionService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("region.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("next-code")]
[Authorize("region.view")]
public async Task<IActionResult> GetNextCode() =>
Ok(await service.GetNextCodeAsync());
[HttpGet("{id:guid}")]
[Authorize("region.view")]
public async Task<IActionResult> GetById(Guid id)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, id))
return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize("region.manage")]
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -25,6 +41,7 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("region.manage")]
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -32,14 +49,46 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("region.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
//targets/budgets/agencies/tax-rule.
[HttpGet("{id:guid}/manager")]
[Authorize("region.view")]
public async Task<IActionResult> GetManager(Guid id)
{
var result = await service.GetManagerAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/manager")]
[Authorize("region.manage")]
public async Task<IActionResult> SetManager(Guid id, SetRegionManagerRequest request, CancellationToken ct)
{
try
{
var result = await service.SetManagerAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}/manager")]
[Authorize("region.manage")]
public async Task<IActionResult> RemoveManager(Guid id, CancellationToken ct)
{
var removed = await service.RemoveManagerAsync(id, ct);
return removed ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/manage")]
[Authorize("region.manage")]
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
{
var result = await service.ManageAsync(id, request, ct);
@@ -47,6 +96,7 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpGet("{id:guid}/hierarchy")]
[Authorize("region.view")]
public async Task<IActionResult> GetHierarchy(Guid id)
{
var result = await service.GetHierarchyAsync(id);
@@ -54,18 +104,20 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpGet("{id:guid}/dashboard")]
[Authorize("region.view")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
// Flat CRUD-list view over targets/budgets/tax-rules/agency-maps across all regions.
[HttpGet("manage-entries")]
[Authorize("region.view")]
public async Task<IActionResult> GetManageEntries() =>
Ok(await service.GetManageEntriesAsync());
[HttpDelete("manage-entries/{entryType}/{id:guid}")]
[Authorize("region.manage")]
public async Task<IActionResult> DeleteManageEntry(RegionManageEntryType entryType, Guid id, CancellationToken ct)
{
var deleted = await service.DeleteManageEntryAsync(entryType, id, ct);
+13 -3
View File
@@ -1,10 +1,14 @@
namespace Distribution.Features.Region;
public record CreateRegionRequest(string Name);
// Code is optional: blank/null auto-generates the next REG-#### code; otherwise stored as entered (trimmed).
public record CreateRegionRequest(string Name, string? Code = null);
public record UpdateRegionRequest(string Name);
public record NextRegionCodeResponse(string Code);
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
// Code is auto-generated on create; on update it is optional (null/blank keeps the current code).
public record UpdateRegionRequest(string Name, string? Code = null);
public record RegionResponse(Guid Id, string Code, string Name, bool IsActive, DateTime CreatedAt);
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
@@ -61,3 +65,9 @@ public record RegionManageEntryResponse(
string? RuleConfig,
Guid? AgencyUserId,
DateTime CreatedAt);
// Region manager = the single active user with the RegionalManager role scoped to the region
// (UserRegionScope). Users themselves are owned by the auth service; this only links one to a region.
public record SetRegionManagerRequest(Guid UserId);
public record RegionManagerResponse(Guid RegionId, Guid UserId, string Username, string FullName);
@@ -11,6 +11,32 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public async Task<List<Models.Region>> GetAllAsync() =>
await context.Regions.ToListAsync();
// Includes soft-deleted regions: the unique index on Code doesn't know about IsActive.
public async Task<List<string>> GetAllCodesAsync() =>
await context.Regions.Select(r => r.Code).ToListAsync();
public async Task<bool> CodeExistsAsync(string code, Guid? excludeId = null) =>
await context.Regions.AnyAsync(r => r.Code == code && (excludeId == null || r.Id != excludeId));
public async Task<Models.User?> GetUserWithRoleAsync(Guid userId) =>
await context.Users.Include(u => u.Role).FirstOrDefaultAsync(u => u.Id == userId);
public async Task<List<Models.UserRegionScope>> GetManagerScopesAsync(Guid regionId) =>
await context.UserRegionScopes
.Include(s => s.User).ThenInclude(u => u.Role)
.Where(s => s.FK_RegionId == regionId && s.User.IsActive && s.User.Role.Name == "RegionalManager")
.ToListAsync();
public async Task<Models.UserRegionScope?> GetRegionScopeAsync(Guid userId, Guid regionId) =>
await context.UserRegionScopes.FirstOrDefaultAsync(s => s.FK_UserId == userId && s.FK_RegionId == regionId);
public async Task AddRegionScopeAsync(Models.UserRegionScope scope) =>
await context.UserRegionScopes.AddAsync(scope);
// Scope rows are hard-deleted (composite key user+region), same as UserRepository.ReplaceRegionScopesAsync.
public void RemoveRegionScopes(IEnumerable<Models.UserRegionScope> scopes) =>
context.UserRegionScopes.RemoveRange(scopes);
public async Task AddAsync(Models.Region entity) =>
await context.Regions.AddAsync(entity);
+97 -5
View File
@@ -6,6 +6,7 @@ namespace Distribution.Features.Region;
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{
private const int RegionalAgencyRoleLevel = 60;
private const string CodePrefix = "REG-";
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
// the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
@@ -14,19 +15,42 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
public async Task<List<RegionResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RegionResponse(r.Id, r.Name, r.IsActive, r.CreatedAt))
.Select(r => new RegionResponse(r.Id, r.Code, r.Name, r.IsActive, r.CreatedAt))
.ToList();
public async Task<RegionResponse?> GetByIdAsync(Guid id)
{
var region = await repo.GetByIdAsync(id);
if (region is null || !region.IsActive) return null;
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
}
// Next code = highest existing REG-#### number + 1 (soft-deleted regions included so codes are never reused).
public async Task<NextRegionCodeResponse> GetNextCodeAsync() => new(await GenerateCodeAsync());
private async Task<string> GenerateCodeAsync()
{
var max = (await repo.GetAllCodesAsync())
.Where(c => c.StartsWith(CodePrefix, StringComparison.OrdinalIgnoreCase)
&& int.TryParse(c[CodePrefix.Length..], out _))
.Select(c => int.Parse(c[CodePrefix.Length..]))
.DefaultIfEmpty(0)
.Max();
return $"{CodePrefix}{max + 1:D4}";
}
public async Task<RegionResponse> CreateAsync(CreateRegionRequest request, CancellationToken ct = default)
{
var region = new Models.Region { Name = request.Name };
string code;
if (string.IsNullOrWhiteSpace(request.Code))
code = await GenerateCodeAsync();
else
{
code = request.Code.Trim();
if (await repo.CodeExistsAsync(code))
throw new InvalidOperationException($"Region code '{code}' is already in use.");
}
var region = new Models.Region { Name = request.Name, Code = code };
await uow.BeginAsync(ct);
try
{
@@ -38,7 +62,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
await uow.RollbackAsync(ct);
throw;
}
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
}
public async Task<RegionResponse?> UpdateAsync(Guid id, UpdateRegionRequest request, CancellationToken ct = default)
@@ -46,6 +70,13 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
var region = await repo.GetByIdAsync(id);
if (region is null || !region.IsActive) return null;
region.Name = request.Name;
if (!string.IsNullOrWhiteSpace(request.Code))
{
var code = request.Code.Trim();
if (code != region.Code && await repo.CodeExistsAsync(code, id))
throw new InvalidOperationException($"Region code '{code}' is already in use.");
region.Code = code;
}
await uow.BeginAsync(ct);
try
{
@@ -57,7 +88,68 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
await uow.RollbackAsync(ct);
throw;
}
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
}
private const string RegionalManagerRole = "RegionalManager";
public async Task<RegionManagerResponse?> GetManagerAsync(Guid regionId)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
var scope = (await repo.GetManagerScopesAsync(regionId)).FirstOrDefault();
return scope is null ? null : new RegionManagerResponse(regionId, scope.FK_UserId, scope.User.Username, scope.User.FullName);
}
// A region has exactly one manager: assigning replaces any existing RegionalManager scoped to it.
// Returns null when the region does not exist; throws InvalidOperationException for an invalid user.
public async Task<RegionManagerResponse?> SetManagerAsync(Guid regionId, SetRegionManagerRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
var user = await repo.GetUserWithRoleAsync(request.UserId);
if (user is null || !user.IsActive)
throw new InvalidOperationException("User not found.");
if (user.Role?.Name != RegionalManagerRole)
throw new InvalidOperationException($"User must have the {RegionalManagerRole} role to manage a region.");
await uow.BeginAsync(ct);
try
{
var existing = await repo.GetManagerScopesAsync(regionId);
repo.RemoveRegionScopes(existing.Where(s => s.FK_UserId != user.Id));
if (await repo.GetRegionScopeAsync(user.Id, regionId) is null)
await repo.AddRegionScopeAsync(new Models.UserRegionScope { FK_UserId = user.Id, FK_RegionId = regionId });
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionManagerResponse(regionId, user.Id, user.Username, user.FullName);
}
public async Task<bool> RemoveManagerAsync(Guid regionId, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return false;
var existing = await repo.GetManagerScopesAsync(regionId);
if (existing.Count == 0) return false;
await uow.BeginAsync(ct);
try
{
repo.RemoveRegionScopes(existing);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Returns;
@@ -7,10 +8,12 @@ namespace Distribution.Features.Returns;
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/returns")]
[Authorize("return.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/returns/{id:guid}")]
[Authorize("return.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,6 +21,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
}
[HttpPatch("api/returns/{id:guid}/reasons")]
[Authorize("return.manage")]
public async Task<IActionResult> UpdateReasons(Guid id, UpdateSaleReturnReasonsRequest request, CancellationToken ct)
{
try
@@ -29,10 +33,12 @@ public class ReturnController(ReturnService service, ICurrentUserService current
}
[HttpGet("api/shop-pos/{id:guid}/returns")]
[Authorize("return.view")]
public async Task<IActionResult> GetByShopPO(Guid id) =>
Ok(await service.GetByShopPOIdAsync(id));
[HttpPost("api/shop-pos/{id:guid}/returns")]
[Authorize("return.manage")]
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.CreatedByUserId;
@@ -42,7 +48,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
var result = await service.CreateAsync(id, request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (NotImplementedException ex) { return StatusCode(501, new { error = ex.Message }); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+71 -3
View File
@@ -1,3 +1,4 @@
using Distribution.Features.Category;
using Distribution.Features.Item;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
@@ -10,7 +11,8 @@ public class ReturnService(
IReturnRepository repo,
IShopPORepository shopPORepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
ICategoryRepository categoryRepo,
StockMover mover,
IUnitOfWork uow)
{
private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
@@ -57,6 +59,72 @@ public class ReturnService(
public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) =>
(await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList();
public Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) =>
throw new NotImplementedException("Return flow is being redesigned — not yet implemented.");
/// <summary>
/// Stock-only: records the return and puts the goods back on the vehicle that delivered them (restoring the
/// batches/serials that were sold). Credit/exchange money handling is not applied here, so TotalCreditAmount stays 0.
/// </summary>
public async Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
{
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (shopPO is null || !shopPO.IsActive)
throw new InvalidOperationException("Order not found.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A return must have at least one line item.");
if (request.Items.GroupBy(i => i.ItemId).Any(g => g.Count() > 1))
throw new InvalidOperationException("Each item can appear only once on a return.");
var saleReturn = new SaleReturn
{
FK_ShopPOId = shopPOId,
Mode = request.Mode,
TotalCreditAmount = 0,
CreatedByUserId = createdByUserId
};
foreach (var line in request.Items)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
var shopPOItem = shopPO.Items.FirstOrDefault(i => i.FK_ItemId == line.ItemId && i.IsActive)
?? throw new InvalidOperationException($"Item {line.ItemId} is not on this order.");
var item = await itemRepo.GetByIdAsync(line.ItemId)
?? throw new InvalidOperationException("Item not found.");
var category = await categoryRepo.GetByIdAsync(item.FK_CategoryId)
?? throw new InvalidOperationException("Category not found.");
if (!category.AllowCustomerReturn)
throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'.");
saleReturn.Items.Add(new SaleReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
ReasonCode = line.ReasonCode,
UnitPrice = shopPOItem.UnitPrice
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(saleReturn);
foreach (var line in saleReturn.Items)
{
var itemName = shopPO.Items.First(i => i.FK_ItemId == line.FK_ItemId).Item?.Name ?? line.FK_ItemId.ToString();
await mover.ReturnToVehicleAsync(line.FK_ItemId, itemName, line.Quantity, shopPOId, createdByUserId, line.ReasonCode.ToString());
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(saleReturn.Id);
return Map(saved!);
}
}
+4 -3
View File
@@ -1,11 +1,12 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Ride;
[ApiController]
[Route("api/rides")]
public class RideController(RideService service) : ControllerBase
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
@@ -54,7 +55,7 @@ public class RideController(RideService service) : ControllerBase
{
try
{
var result = await service.CloseAsync(id, request, ct);
var result = await service.CloseAsync(id, request, currentUser.UserId ?? Guid.Empty, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -66,7 +67,7 @@ public class RideController(RideService service) : ControllerBase
{
try
{
var result = await service.CancelAsync(id, request, ct);
var result = await service.CancelAsync(id, request, currentUser.UserId ?? Guid.Empty, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
+2
View File
@@ -13,6 +13,8 @@ public record RideResponse(
int Meterend,
DateTime? StartedAt,
DateTime? ClosedAt,
CancelRequestType? CancelTypeCode,
string? CancelReason,
DateTime CreatedAt);
public record CreateRideRequest(
+19 -15
View File
@@ -10,7 +10,7 @@ public class RideService(
IRideRepository rideRepo,
IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo,
IStockRepository stockRepo,
StockMover mover,
IUnitOfWork uow)
{
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
@@ -76,7 +76,7 @@ public class RideService(
return ToResponse(saved!);
}
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default)
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, Guid userId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
@@ -87,20 +87,24 @@ public class RideService(
if (request.Meterend < ride.Meterstart)
throw new InvalidOperationException($"Meter end ({request.Meterend}) cannot be less than meter start ({ride.Meterstart})");
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
ride.Status = RideStatus.Closed;
ride.ClosedAt = DateTime.Now;
ride.Meterend = request.Meterend;
// Whatever wasn't delivered goes back to the warehouse it was loaded from.
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
try
{
await mover.UnloadVehicleAsync(ride.FK_VehicleId, ride.Id, userId, "RideClosed");
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
}
public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest request, CancellationToken ct = default)
public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest request, Guid userId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
@@ -109,9 +113,16 @@ public class RideService(
throw new InvalidOperationException($"Cannot cancel a ride in {ride.Status} status");
ride.Status = RideStatus.Cancelled;
ride.CancelTypeCode = request.TypeCode;
ride.CancelReason = request.Reason;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
try
{
await mover.UnloadVehicleAsync(ride.FK_VehicleId, ride.Id, userId, "RideCancelled");
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
@@ -137,17 +148,10 @@ public class RideService(
throw new InvalidOperationException("Driver medical clearance has expired");
}
private async Task EnsureVehicleStockClearedAsync(Guid vehicleId)
{
var balances = await stockRepo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle);
if (balances.Any(b => b.Quantity > 0))
throw new InvalidOperationException("All vehicle stock must be returned before this ride can be completed");
}
private static RideResponse ToResponse(DeliveryRun r) =>
new(r.Id, r.Status,
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
r.FK_DriverProfileId, r.DriverProfile?.User?.FullName ?? string.Empty,
r.Meterstart, r.Meterend,
r.StartedAt, r.ClosedAt, r.CreatedAt);
r.StartedAt, r.ClosedAt, r.CancelTypeCode, r.CancelReason, r.CreatedAt);
}
@@ -5,4 +5,8 @@ public interface IRoleRepository
Task<Models.Role?> GetByIdAsync(Guid id);
Task<List<Models.Role>> GetAllAsync();
Task<List<Models.RolePermission>> GetPermissionsAsync(Guid roleId);
Task AddAsync(Models.Role role);
void Update(Models.Role role);
void Remove(Models.Role role);
Task ReplacePermissionsAsync(Guid roleId, List<Guid> permissionIds);
}
@@ -23,4 +23,32 @@ public class RoleController(RoleService service) : ControllerBase
var result = await service.GetPermissionsAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateRoleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateRoleRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/permissions")]
public async Task<IActionResult> AssignPermissions(Guid id, AssignRolePermissionsRequest request, CancellationToken ct)
{
var result = await service.AssignPermissionsAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var deactivated = await service.DeactivateAsync(id, ct);
return deactivated ? NoContent() : NotFound();
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
namespace Distribution.Features.Role;
public record RoleResponse(Guid Id, string Name, int Level, bool IsActive, DateTime CreatedAt);
public record RolePermissionResponse(Guid PermissionId, string Code, string Description);
public record CreateRoleRequest(string Name, int Level);
public record UpdateRoleRequest(string Name, int Level);
public record AssignRolePermissionsRequest(List<Guid> PermissionIds);
@@ -1,4 +1,5 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Role;
@@ -16,4 +17,29 @@ public class RoleRepository(AppDBContext context) : IRoleRepository
.Include(rp => rp.Permission)
.Where(rp => rp.FK_RoleId == roleId && rp.IsActive)
.ToListAsync();
public async Task AddAsync(Models.Role role) =>
await context.Roles.AddAsync(role);
public void Update(Models.Role role) =>
context.Roles.Update(role);
public void Remove(Models.Role role)
{
role.IsActive = false;
context.Roles.Update(role);
}
public async Task ReplacePermissionsAsync(Guid roleId, List<Guid> permissionIds)
{
var existing = await context.RolePermissions
.Where(rp => rp.FK_RoleId == roleId)
.ToListAsync();
context.RolePermissions.RemoveRange(existing);
context.RolePermissions.AddRange(permissionIds.Select(pid => new RolePermission
{
FK_RoleId = roleId,
FK_PermissionId = pid
}));
}
}
+81 -3
View File
@@ -1,18 +1,23 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.Role;
public class RoleService(IRoleRepository repo)
public class RoleService(IRoleRepository repo, IUnitOfWork uow)
{
private static RoleResponse ToResponse(Models.Role r) =>
new(r.Id, r.Name, r.Level, r.IsActive, r.CreatedAt);
public async Task<List<RoleResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RoleResponse(r.Id, r.Name, r.Level, r.IsActive, r.CreatedAt))
.Select(ToResponse)
.ToList();
public async Task<RoleResponse?> GetByIdAsync(Guid id)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return null;
return new RoleResponse(role.Id, role.Name, role.Level, role.IsActive, role.CreatedAt);
return ToResponse(role);
}
public async Task<List<RolePermissionResponse>?> GetPermissionsAsync(Guid roleId)
@@ -23,4 +28,77 @@ public class RoleService(IRoleRepository repo)
.Select(rp => new RolePermissionResponse(rp.FK_PermissionId, rp.Permission.Code, rp.Permission.Description))
.ToList();
}
public async Task<RoleResponse> CreateAsync(CreateRoleRequest request, CancellationToken ct = default)
{
var role = new Models.Role { Name = request.Name, Level = request.Level };
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(role);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(role);
}
public async Task<RoleResponse?> UpdateAsync(Guid id, UpdateRoleRequest request, CancellationToken ct = default)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return null;
role.Name = request.Name;
role.Level = request.Level;
await uow.BeginAsync(ct);
try
{
repo.Update(role);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(role);
}
public async Task<bool> DeactivateAsync(Guid id, CancellationToken ct = default)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return false;
await uow.BeginAsync(ct);
try
{
repo.Remove(role);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
public async Task<List<RolePermissionResponse>?> AssignPermissionsAsync(Guid id, AssignRolePermissionsRequest request, CancellationToken ct = default)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return null;
await uow.BeginAsync(ct);
try
{
await repo.ReplacePermissionsAsync(id, request.PermissionIds);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return await GetPermissionsAsync(id);
}
}
+21 -2
View File
@@ -1,23 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Route;
[ApiController]
[Route("api/routes")]
public class RouteController(RouteService service) : ControllerBase
public class RouteController(
RouteService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("route.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize("route.view")]
public async Task<IActionResult> GetById(Guid id)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRouteScopeAsync(userId.Value, id))
return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize("route.manage")]
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -25,6 +36,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("route.manage")]
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -32,6 +44,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}/region")]
[Authorize("route.manage")]
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
{
var result = await service.AssignToRegionAsync(id, request, ct);
@@ -39,6 +52,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("route.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -46,6 +60,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost("{id:guid}/stores")]
[Authorize("route.manage")]
public async Task<IActionResult> AssignStores(Guid id, AssignStoresToRouteRequest request, CancellationToken ct)
{
var result = await service.AssignStoresToRouteAsync(id, request, ct);
@@ -53,6 +68,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}/owner")]
[Authorize("route.manage")]
public async Task<IActionResult> AssignOwner(Guid id, AssignRouteOwnerRequest request, CancellationToken ct)
{
var result = await service.AssignOwnerAsync(id, request, ct);
@@ -60,10 +76,12 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost("{id:guid}/optimize")]
[Authorize("route.manage")]
public IActionResult OptimizeSequence(Guid id) =>
StatusCode(StatusCodes.Status501NotImplemented);
[HttpPost("{id:guid}/visits/check-in")]
[Authorize("route.manage")]
public async Task<IActionResult> CheckIn(Guid id, RecordCheckInRequest request, CancellationToken ct)
{
try
@@ -75,6 +93,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost("{id:guid}/visits/check-out")]
[Authorize("route.manage")]
public async Task<IActionResult> CheckOut(Guid id, RecordCheckOutRequest request, CancellationToken ct)
{
var result = await service.RecordCheckOutAsync(id, request, ct);
+3 -3
View File
@@ -1,10 +1,10 @@
namespace Distribution.Features.Route;
public record CreateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
public record CreateRouteRequest(Guid RegionId, string RouteName, string? StartPoint, string? EndPoint, decimal? Length);
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
public record UpdateRouteRequest(Guid RegionId, string RouteName, string? StartPoint, string? EndPoint, decimal? Length, bool? IsActive = null);
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, Guid? OwnerUserId, bool IsActive, DateTime CreatedAt);
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string? StartPoint, string? EndPoint, decimal? Length, Guid? OwnerUserId, bool IsActive, DateTime CreatedAt);
public record AssignRouteToRegionRequest(Guid RegionId);
+9 -4
View File
@@ -10,8 +10,12 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
private async Task<bool> CanManageRouteAsync(Guid routeId)
{
if (currentUser.RoleLevel < RouteManagerRoleLevel || currentUser.UserId is null) return false;
return await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, routeId);
// TEMPORARY: role-floor and route-scope checks bypassed so routes can be assigned
// while auth doesn't resolve the current user (testbug.md #28). Restore the original:
// if (currentUser.RoleLevel < RouteManagerRoleLevel || currentUser.UserId is null) return false;
// return await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, routeId);
await Task.CompletedTask;
return true;
}
private static double DistanceMeters(decimal lat1, decimal lon1, decimal lat2, decimal lon2)
@@ -36,7 +40,6 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
public async Task<List<RouteResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.FK_OwnerUserId, r.IsActive, r.CreatedAt))
.ToList();
@@ -74,12 +77,14 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
public async Task<RouteResponse?> UpdateAsync(Guid id, UpdateRouteRequest request, CancellationToken ct = default)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
// Inactive routes stay editable so they can be reactivated.
if (route is null) return null;
route.FK_RegionId = request.RegionId;
route.RouteName = request.RouteName;
route.StartPoint = request.StartPoint;
route.EndPoint = request.EndPoint;
route.Length = request.Length;
if (request.IsActive.HasValue) route.IsActive = request.IsActive.Value;
await uow.BeginAsync(ct);
try
{
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Scheme;
@@ -7,10 +8,12 @@ namespace Distribution.Features.Scheme;
public class SchemeController(SchemeService service) : ControllerBase
{
[HttpGet]
[Authorize("scheme.view")]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[HttpGet("{id:guid}")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,6 +21,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPost]
[Authorize("scheme.manage")]
public async Task<IActionResult> Create(CreateSchemeRequest request, CancellationToken ct)
{
try
@@ -32,6 +36,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> Update(Guid id, UpdateSchemeRequest request, CancellationToken ct)
{
try
@@ -46,6 +51,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -53,6 +59,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPut("{id:guid}/free-goods-rule")]
[Authorize("scheme.manage")]
public async Task<IActionResult> SetFreeGoodsRule(Guid id, SetFreeGoodsRuleRequest request, CancellationToken ct)
{
try
@@ -67,6 +74,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpDelete("{id:guid}/free-goods-rule")]
[Authorize("scheme.manage")]
public async Task<IActionResult> ClearFreeGoodsRule(Guid id, CancellationToken ct)
{
var result = await service.ClearFreeGoodsRuleAsync(id, ct);
@@ -74,6 +82,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPut("{id:guid}/bundle-components/{itemId:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> UpsertBundleComponent(Guid id, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct)
{
try
@@ -88,6 +97,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpDelete("{id:guid}/bundle-components/{itemId:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> RemoveBundleComponent(Guid id, Guid itemId, CancellationToken ct)
{
var result = await service.RemoveBundleComponentAsync(id, itemId, ct);
@@ -95,6 +105,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpGet("eligible")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetEligible([FromQuery] Guid storeId, [FromQuery] Guid orderId)
{
var result = await service.EvaluateEligibilityAsync(storeId, orderId);
@@ -102,6 +113,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpGet("{id:guid}/spend")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetSpend(Guid id)
{
var result = await service.GetSpendAsync(id);
@@ -109,6 +121,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpGet("{id:guid}/roi")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetROI(Guid id)
{
var result = await service.GetROIAsync(id);
@@ -121,6 +134,7 @@ public class SchemeController(SchemeService service) : ControllerBase
public class SchemeOrderController(SchemeService service) : ControllerBase
{
[HttpPost("{id:guid}/apply-scheme")]
[Authorize("scheme.manage")]
public async Task<IActionResult> ApplyScheme(Guid id, ApplySchemeRequest request, CancellationToken ct)
{
try
@@ -5,11 +5,12 @@ namespace Distribution.Features.ShopPO;
public interface IShopPORepository
{
Task<Models.ShopPO?> GetByIdAsync(Guid id);
Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed);
Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status);
Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId);
Task<List<Models.ShopPOItem>> GetActiveItemsByShopPOIdAsync(Guid shopPOId);
Task<List<Models.ShopPO>> GetByStoreIdAsync(Guid storeId);
Task<Dictionary<Guid, (string Name, string Role)>> GetUserInfoAsync(IEnumerable<Guid> userIds);
Task AddAsync(Models.ShopPO entity);
void Update(Models.ShopPO entity);
void Remove(Models.ShopPO entity);
@@ -1,21 +1,25 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ShopPO;
[ApiController]
[Route("api/shop-pos")]
public class ShopPOController(ShopPOService service) : ControllerBase
public class ShopPOController(ShopPOService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? agentId,
[FromQuery] Guid? routeId,
[FromQuery] Guid? regionId,
[FromQuery] Guid? shopId,
[FromQuery] bool? confirmed,
[FromQuery] ShopPOStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
var result = await service.GetAllAsync(agentId, routeId, regionId, shopId, confirmed, status, page, pageSize);
return Ok(result);
}
@@ -25,4 +29,79 @@ public class ShopPOController(ShopPOService service) : ControllerBase
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.RaisedByUserId;
if (userId is null) return BadRequest(new { error = "RaisedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateAsync(request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/items")]
public async Task<IActionResult> UpdateItems(Guid id, UpdateShopPOItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateItemsAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, ConfirmShopPORequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
try
{
var result = await service.ConfirmAsync(id, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-and-create-invoice")]
public async Task<IActionResult> ConfirmAndCreateInvoice(Guid id, ConfirmShopPORequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
try
{
var result = await service.ConfirmAndCreateInvoiceAsync(id, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancelShopPORequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.CancelledByUserId;
if (userId is null) return BadRequest(new { error = "CancelledByUserId is required while auth is unavailable" });
try
{
var result = await service.CancelAsync(id, userId.Value, request.Reason, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
+15 -2
View File
@@ -2,9 +2,11 @@
namespace Distribution.Features.ShopPO;
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items);
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
@@ -12,6 +14,8 @@ public record ShopPOResponse(
Guid Id,
Guid StoreId,
string StoreName,
string? StoreAddress,
string? StoreContactNumber,
Guid RaisedById,
Guid? InvoiceId,
decimal TotalAmount,
@@ -21,4 +25,13 @@ public record ShopPOResponse(
DateTime? FulfilledAt,
List<ShopPOItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
DateTime CreatedAt,
string? RaisedByName = null,
string? RaisedByRole = null,
string? ConfirmedByName = null,
string? ConfirmedByRole = null,
Guid? CancelledBy = null,
DateTime? CancelledAt = null,
string? CancelReason = null,
string? CancelledByName = null,
string? CancelledByRole = null);
@@ -13,27 +13,32 @@ public class ShopPORepository(AppDBContext context) : IShopPORepository
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
public async Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize) =>
public async Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize) =>
await context.ShopPOs
.Include(p => p.Store)
.Include(p => p.Store).ThenInclude(s => s.Route)
.Include(p => p.Items.Where(i => i.IsActive)).ThenInclude(i => i.Item)
.Where(p => p.IsActive
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (regionId == null || p.Store.Route.FK_RegionId == regionId)
&& (shopId == null || p.FK_StoreId == shopId)
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)))
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null))
&& (status == null || p.Status == status))
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed) =>
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status) =>
await context.ShopPOs
.Include(p => p.Store)
.Include(p => p.Store).ThenInclude(s => s.Route)
.CountAsync(p => p.IsActive
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (regionId == null || p.Store.Route.FK_RegionId == regionId)
&& (shopId == null || p.FK_StoreId == shopId)
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)));
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null))
&& (status == null || p.Status == status));
public async Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId) =>
await context.ShopPOs
@@ -59,6 +64,16 @@ public class ShopPORepository(AppDBContext context) : IShopPORepository
.Where(p => p.FK_StoreId == storeId && p.IsActive)
.ToListAsync();
public async Task<Dictionary<Guid, (string Name, string Role)>> GetUserInfoAsync(IEnumerable<Guid> userIds)
{
var ids = userIds.Distinct().ToList();
var users = await context.Users
.Where(u => ids.Contains(u.Id))
.Select(u => new { u.Id, u.FullName, RoleName = u.Role.Name })
.ToListAsync();
return users.ToDictionary(u => u.Id, u => (u.FullName, u.RoleName));
}
public async Task AddAsync(Models.ShopPO entity) =>
await context.ShopPOs.AddAsync(entity);
+212 -8
View File
@@ -1,4 +1,6 @@
using Distribution.Features.Stock;
using Distribution.Features.Invoice;
using Distribution.Features.PriceList;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -6,27 +8,229 @@ namespace Distribution.Features.ShopPO;
public class ShopPOService(
IShopPORepository repo,
IInvoiceRepository invoiceRepo,
PriceListService priceListService,
IUnitOfWork uow)
{
private static ShopPOResponse ToResponse(Models.ShopPO p) =>
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.Store?.StoreAddress, p.Store?.ContactNumber,
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
p.Items.Where(i => i.IsActive).Select(i =>
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
p.IsActive, p.CreatedAt);
p.IsActive, p.CreatedAt,
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
users != null && p.ConfirmedBy is Guid cb && users.TryGetValue(cb, out var c) ? c.Name : null,
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
p.CancelledBy, p.CancelledAt, p.CancelReason,
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
public async Task<PagedResponse<ShopPOResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize)
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, confirmed);
var items = await repo.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
return new PagedResponse<ShopPOResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
yield return p.RaisedById;
if (p.ConfirmedBy is Guid cb) yield return cb;
if (p.CancelledBy is Guid xb) yield return xb;
}
private async Task<ShopPOResponse> ToResponseWithUserAsync(Models.ShopPO p) =>
ToResponse(p, await repo.GetUserInfoAsync(UserIds(p)));
public async Task<PagedResponse<ShopPOResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize)
{
var total = await repo.GetCountAsync(agentId, routeId, regionId, shopId, confirmed, status);
var items = await repo.GetAllAsync(agentId, routeId, regionId, shopId, confirmed, status, page, pageSize);
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
return new PagedResponse<ShopPOResponse>(page, pageSize, total, items.Select(p => ToResponse(p, users)).ToList());
}
public async Task<ShopPOResponse?> GetByIdAsync(Guid id)
{
var po = await repo.GetByIdAsync(id);
return po is null ? null : ToResponse(po);
if (po is null) return null;
var users = await repo.GetUserInfoAsync(UserIds(po));
return ToResponse(po, users);
}
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
{
if (request.Items.Count == 0)
throw new InvalidOperationException("At least one item is required");
var items = new List<ShopPOItem>();
decimal total = 0;
foreach (var line in request.Items)
{
var unitPrice = line.UnitPrice
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
items.Add(new ShopPOItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
QuantityFulfilled = 0,
UnitPrice = unitPrice
});
total += line.Quantity * unitPrice;
}
var po = new Models.ShopPO
{
FK_StoreId = request.StoreId,
RaisedById = raisedById,
TotalAmount = total,
Status = ShopPOStatus.Pending
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(po);
foreach (var item in items) item.FK_ShopPOId = po.Id;
await repo.AddItemsAsync(items);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(po.Id);
return await ToResponseWithUserAsync(saved!);
}
public async Task<ShopPOResponse> UpdateItemsAsync(Guid id, UpdateShopPOItemsRequest request, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Cannot edit a cancelled Shop PO");
if (po.ConfirmedAt is not null)
throw new InvalidOperationException("Items can only be changed before the Shop PO is confirmed");
if (request.Items.Count == 0)
throw new InvalidOperationException("At least one item is required");
var newItems = new List<ShopPOItem>();
decimal total = 0;
foreach (var line in request.Items)
{
var unitPrice = line.UnitPrice
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
newItems.Add(new ShopPOItem
{
FK_ShopPOId = id,
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
QuantityFulfilled = 0,
UnitPrice = unitPrice
});
total += line.Quantity * unitPrice;
}
po.TotalAmount = total;
await uow.BeginAsync(ct);
try
{
await repo.ReplaceItemsAsync(id, newItems);
repo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(id);
return await ToResponseWithUserAsync(saved!);
}
public async Task<ShopPOResponse> ConfirmAsync(Guid id, Guid confirmedBy, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.ConfirmedAt is not null)
throw new InvalidOperationException("Shop PO is already confirmed");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Cannot confirm a cancelled Shop PO");
po.ConfirmedAt = DateTime.Now;
po.ConfirmedBy = confirmedBy;
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return await ToResponseWithUserAsync(po);
}
public async Task<ShopPOResponse> ConfirmAndCreateInvoiceAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.ConfirmedAt is not null)
throw new InvalidOperationException("Shop PO is already confirmed");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Cannot confirm a cancelled Shop PO");
po.ConfirmedAt = DateTime.Now;
po.ConfirmedBy = userId;
var invoice = new Models.Invoice
{
FK_ShopPOId = po.Id,
Status = InvoiceStatus.Draft,
PaymentStatus = PaymentStatus.Pending,
CreatedBy = userId,
TotalAmount = po.TotalAmount
};
await uow.BeginAsync(ct);
try
{
await invoiceRepo.AddAsync(invoice);
po.InvoiceId = invoice.Id;
repo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(id);
return await ToResponseWithUserAsync(saved!);
}
public async Task<ShopPOResponse> CancelAsync(Guid id, Guid cancelledBy, string reason, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(reason))
throw new InvalidOperationException("A cancellation reason is required");
var po = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Shop PO is already cancelled");
if (po.Status is ShopPOStatus.PartiallyFulfilled or ShopPOStatus.Fulfilled)
throw new InvalidOperationException($"Cannot cancel a Shop PO in {po.Status} status");
po.Status = ShopPOStatus.Cancelled;
po.CancelledAt = DateTime.Now;
po.CancelledBy = cancelledBy;
po.CancelReason = reason.Trim();
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return await ToResponseWithUserAsync(po);
}
}
@@ -6,10 +6,14 @@ public interface IStockRepository
{
// Balance
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId);
/// <summary>Positive balances for the item at the location in pick order: batches by earliest expiry, serials oldest first, untracked last.</summary>
Task<List<StockBalance>> GetAllocatableBalancesAsync(Guid itemId, Guid locationId);
Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType);
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
Task<List<StockBalance>> GetBalancesByItemAsync(Guid itemId);
Task<List<StockBalance>> GetLowStockAsync(Guid? locationId);
Task AddBalanceAsync(StockBalance balance);
void UpdateBalance(StockBalance balance);
@@ -17,6 +21,23 @@ public interface IStockRepository
Task AddMovementAsync(StockMovement movement);
Task<StockMovement?> GetMovementByIdAsync(Guid id);
void UpdateMovement(StockMovement movement);
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
Task<List<StockMovement>> GetMovementsAsync(MovementFilter filter, int page, int pageSize);
Task<int> GetMovementsCountAsync(MovementFilter filter);
/// <summary>Opening balance, quantity in/out within [from, toExclusive), closing balance, and the live balance to reconcile against.</summary>
Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime toExclusive);
Task<List<StockMovement>> GetMovementsByReferenceAsync(Guid referenceId, Guid itemId, MovementType type);
/// <summary>The warehouse the item most recently arrived at <paramref name="vehicleId"/> from, or null if it never did.</summary>
Task<Guid?> GetLastWarehouseSourceAsync(Guid vehicleId, Guid itemId);
// Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null).
Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber);
Task AddItemBatchAsync(ItemBatch batch);
// Serial numbers
Task<ItemSerialNumber?> GetSerialAsync(Guid serialId);
Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber);
/// <summary>One past the highest numeric suffix among the item's existing serials that start with <paramref name="prefix"/> (1 if none).</summary>
Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix);
Task AddItemSerialNumberAsync(ItemSerialNumber serial);
}
+48 -4
View File
@@ -1,5 +1,6 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Stock;
@@ -8,9 +9,10 @@ namespace Distribution.Features.Stock;
[Route("api/stock")]
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
{
// ── Query ──────────────────────────────────────────────────────────────
// ── Query ──────────────────────────────────────────────────────────────────
[HttpGet("balances")]
[Authorize("stock.view")]
public async Task<IActionResult> GetBalances(
[FromQuery] Guid? locationId,
[FromQuery] LocationType? locationType,
@@ -22,7 +24,16 @@ public class StockController(StockService service, ICurrentUserService currentUs
return Ok(result);
}
[HttpGet("low-stock")]
[Authorize("stock.view")]
public async Task<IActionResult> GetLowStock([FromQuery] Guid? locationId)
{
var result = await service.GetLowStockAsync(locationId);
return Ok(result);
}
[HttpGet("balances/{itemId}/total")]
[Authorize("stock.view")]
public async Task<IActionResult> GetItemTotal(Guid itemId)
{
var result = await service.GetItemTotalAsync(itemId);
@@ -30,23 +41,52 @@ public class StockController(StockService service, ICurrentUserService currentUs
return Ok(result);
}
/// <summary>Next free number for auto-generated serials (e.g. after ...-0005 the answer is 6).</summary>
[HttpGet("serials/next-sequence")]
[Authorize("stock.view")]
public async Task<IActionResult> GetNextSerialSequence([FromQuery] Guid itemId, [FromQuery] string prefix = "")
{
var next = await service.GetNextSerialSequenceAsync(itemId, prefix);
return Ok(new { next });
}
[HttpGet("movements")]
//[Authorize("stock.view")]
public async Task<IActionResult> GetMovements(
[FromQuery] Guid? locationId,
[FromQuery] Guid? itemId,
[FromQuery] MovementType? type,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] Guid? referenceId,
[FromQuery] Guid? batchId,
[FromQuery] Guid? serialId,
[FromQuery] Guid? createdBy,
[FromQuery] string? reasonCode,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
var filter = new MovementFilter(locationId, itemId, type, from, to, referenceId, batchId, serialId, createdBy, reasonCode);
var result = await service.GetMovementsAsync(filter, page, pageSize);
return Ok(result);
}
// ── Write ──────────────────────────────────────────────────────────────
/// <summary>Opening / in / out / closing quantity for one item at one location over a date range.</summary>
[HttpGet("movements/summary")]
public async Task<IActionResult> GetMovementSummary(
[FromQuery] Guid itemId,
[FromQuery] Guid locationId,
[FromQuery] DateTime from,
[FromQuery] DateTime to)
{
var result = await service.GetMovementSummaryAsync(itemId, locationId, from, to);
return Ok(result);
}
// ── Write ──────────────────────────────────────────────────────────────────
[HttpPost("transfer")]
[Authorize("stock.manage")]
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
@@ -59,6 +99,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPost("damage")]
[Authorize("stock.manage")]
public async Task<IActionResult> RecordDamage(DamageRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
@@ -71,6 +112,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPost("customer-return")]
[Authorize("stock.manage")]
public async Task<IActionResult> RecordCustomerReturn(CustomerReturnRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
@@ -83,6 +125,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPost("adjustment")]
[Authorize("stock.manage")]
public async Task<IActionResult> Adjust(AdjustmentRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -98,6 +141,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPatch("movements/{id}/reason")]
[Authorize("stock.manage")]
public async Task<IActionResult> UpdateMovementReason(Guid id, UpdateMovementReasonRequest request, CancellationToken ct)
{
try
+34 -1
View File
@@ -21,6 +21,14 @@ public record StockTotalResponse(
decimal TotalQuantity,
List<StockBalanceResponse> Breakdown);
public record LowStockItemResponse(
Guid ItemId,
string ItemName,
Guid LocationId,
string LocationName,
decimal Quantity,
decimal ReorderLevel);
// Movement
public record StockMovementResponse(
Guid Id,
@@ -37,7 +45,12 @@ public record StockMovementResponse(
DateTime CreatedAt,
Guid CreatedBy,
string? FromLocationName,
string? ToLocationName);
string? ToLocationName,
Guid? BatchId = null,
string? BatchNumber = null,
DateTime? BatchExpiryDate = null,
Guid? SerialId = null,
string? SerialNumber = null);
// Write requests
public record TransferRequest(
@@ -74,3 +87,23 @@ public record AdjustmentRequest(
// Movements are an immutable ledger — quantity, locations, and type can never change once recorded
// (the running stock balance is the sum of all movements). Only the free-text reason/note is editable.
public record UpdateMovementReasonRequest(string ReasonCode);
// Movement report filters
public record MovementFilter(
Guid? LocationId = null,
Guid? ItemId = null,
MovementType? Type = null,
DateTime? From = null,
DateTime? To = null,
Guid? ReferenceId = null,
Guid? BatchId = null,
Guid? SerialId = null,
Guid? CreatedBy = null,
string? ReasonCode = null);
public record MovementSummary(
decimal OpeningQuantity,
decimal QuantityIn,
decimal QuantityOut,
decimal ClosingQuantity,
decimal CurrentBalance);
+234
View File
@@ -0,0 +1,234 @@
using Distribution.Features.SysConfig;
using Distribution.Models;
namespace Distribution.Features.Stock;
/// <summary>
/// Applies a stock movement end to end: picks which batch/serial balances to take from (FEFO / FIFO),
/// updates the source and destination balances, keeps serial status/location in step, and writes one
/// StockMovement per slice so the ledger always carries the batch/serial that actually moved.
/// Tracks changes only — the calling service owns the unit of work.
/// </summary>
public class StockMover(IStockRepository repo, SysConfigService sysConfig)
{
private static readonly Guid External = StockService.ExternalLocationId;
private readonly record struct StockKey(Guid LocationId, Guid? BatchId, Guid? SerialId);
public async Task<List<StockMovement>> MoveAsync(
Guid itemId, string itemName,
Guid fromId, LocationType fromType,
Guid toId, LocationType toType,
decimal quantity, MovementType type,
Guid? referenceId, Guid userId, string? reasonCode = null)
{
if (quantity <= 0)
throw new InvalidOperationException("Quantity must be greater than zero.");
var balances = await repo.GetAllocatableBalancesAsync(itemId, fromId);
var available = balances.Sum(b => b.Quantity);
if (available < quantity)
throw new InvalidOperationException(
$"Insufficient stock for '{itemName}': available {available}, required {quantity}");
var created = new List<StockMovement>();
var remaining = quantity;
foreach (var balance in balances)
{
if (remaining <= 0) break;
var take = Math.Min(balance.Quantity, remaining);
balance.Quantity -= take;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
if (toType != LocationType.External)
await CreditAsync(itemId, toId, toType, balance.FK_ItemBatchId, balance.FK_ItemSerialNumberId, take);
if (balance.ItemSerialNumber is not null)
await ApplySerialAsync(balance.ItemSerialNumber, balance.Item, toId, toType, type);
var movement = new StockMovement
{
FK_ItemId = itemId,
FromLocationId = fromId,
FromLocationType = fromType,
ToLocationId = toId,
ToLocationType = toType,
FK_ItemBatchId = balance.FK_ItemBatchId,
FK_ItemSerialNumberId = balance.FK_ItemSerialNumberId,
Quantity = take,
Type = type,
ReasonCode = reasonCode,
ReferenceId = referenceId,
CreatedBy = userId
};
await repo.AddMovementAsync(movement);
created.Add(movement);
remaining -= take;
}
return created;
}
/// <summary>
/// Puts returned goods back on the vehicle that delivered them, restoring the same batches/serials that
/// were sold on that ShopPO. Quantity is capped at what was delivered and not yet returned.
/// </summary>
public async Task<List<StockMovement>> ReturnToVehicleAsync(
Guid itemId, string itemName, decimal quantity, Guid shopPoId, Guid userId, string? reasonCode = null)
{
if (quantity <= 0)
throw new InvalidOperationException("Quantity must be greater than zero.");
var sold = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.Sale);
if (sold.Count == 0)
throw new InvalidOperationException($"'{itemName}' was not delivered on this order, so it cannot be returned.");
var returned = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.CustomerReturn);
var outstanding = new Dictionary<StockKey, decimal>();
foreach (var m in sold)
{
var key = new StockKey(m.FromLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId);
outstanding[key] = outstanding.GetValueOrDefault(key) + m.Quantity;
}
foreach (var m in returned)
{
var key = new StockKey(m.ToLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId);
outstanding[key] = outstanding.GetValueOrDefault(key) - m.Quantity;
}
var returnable = outstanding.Where(kv => kv.Value > 0).Sum(kv => kv.Value);
if (quantity > returnable)
throw new InvalidOperationException(
$"Return quantity for '{itemName}' ({quantity}) exceeds the delivered-but-not-yet-returned quantity ({returnable}).");
var created = new List<StockMovement>();
var remaining = quantity;
foreach (var (key, qty) in outstanding.Where(kv => kv.Value > 0))
{
if (remaining <= 0) break;
var take = Math.Min(qty, remaining);
await CreditAsync(itemId, key.LocationId, LocationType.Vehicle, key.BatchId, key.SerialId, take);
if (key.SerialId is Guid serialId)
{
var serial = await repo.GetSerialAsync(serialId);
if (serial is not null)
await ApplySerialAsync(serial, null, key.LocationId, LocationType.Vehicle, MovementType.CustomerReturn);
}
var movement = new StockMovement
{
FK_ItemId = itemId,
FromLocationId = External,
FromLocationType = LocationType.External,
ToLocationId = key.LocationId,
ToLocationType = LocationType.Vehicle,
FK_ItemBatchId = key.BatchId,
FK_ItemSerialNumberId = key.SerialId,
Quantity = take,
Type = MovementType.CustomerReturn,
ReasonCode = reasonCode,
ReferenceId = shopPoId,
CreatedBy = userId
};
await repo.AddMovementAsync(movement);
created.Add(movement);
remaining -= take;
}
return created;
}
/// <summary>
/// Sends everything still on the vehicle back to the warehouse it was loaded from.
/// </summary>
public async Task UnloadVehicleAsync(Guid vehicleId, Guid referenceId, Guid userId, string reasonCode)
{
var onVehicle = (await repo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle))
.Where(b => b.Quantity > 0)
.GroupBy(b => b.FK_ItemId);
foreach (var group in onVehicle)
{
var item = group.First().Item;
var warehouseId = await repo.GetLastWarehouseSourceAsync(vehicleId, group.Key)
?? throw new InvalidOperationException(
$"Cannot unload '{item.Name}': no source warehouse found for the stock on this vehicle.");
await MoveAsync(group.Key, item.Name, vehicleId, LocationType.Vehicle,
warehouseId, LocationType.Warehouse, group.Sum(b => b.Quantity),
MovementType.Transfer, referenceId, userId, reasonCode);
}
}
private async Task CreditAsync(Guid itemId, Guid locationId, LocationType locationType,
Guid? batchId, Guid? serialId, decimal quantity)
{
var balance = await repo.GetBalanceAsync(itemId, locationId, batchId, serialId);
if (balance is null)
{
await repo.AddBalanceAsync(new StockBalance
{
FK_ItemId = itemId,
LocationId = locationId,
LocationType = locationType,
FK_ItemBatchId = batchId,
FK_ItemSerialNumberId = serialId,
Quantity = quantity
});
return;
}
balance.Quantity += quantity;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
}
private async Task ApplySerialAsync(ItemSerialNumber serial, Models.Item? item, Guid toId, LocationType toType, MovementType type)
{
switch (type)
{
case MovementType.Sale:
serial.Status = SerialStatus.Sold;
serial.CurrentLocationId = null;
serial.CurrentLocationType = null;
// Warranty clock starts at sale, not receipt.
if (item is { HasWarranty: true } && await sysConfig.GetWarrantyTrackingEnabledAsync())
{
var days = serial.WarrantyPeriodDaysOverride ?? await sysConfig.GetDefaultWarrantyPeriodDaysAsync();
serial.WarrantyExpiresAt = DateTime.Now.AddDays(days);
}
break;
case MovementType.Damage:
serial.Status = SerialStatus.Damaged;
serial.CurrentLocationId = null;
serial.CurrentLocationType = null;
break;
case MovementType.CustomerReturn:
serial.Status = SerialStatus.Returned;
serial.CurrentLocationId = toId;
serial.CurrentLocationType = toType;
break;
default:
if (toType == LocationType.External)
{
serial.CurrentLocationId = null;
serial.CurrentLocationType = null;
}
else
{
if (type == MovementType.Load) serial.Status = SerialStatus.InStock;
serial.CurrentLocationId = toId;
serial.CurrentLocationType = toType;
}
break;
}
}
}
+143 -23
View File
@@ -7,9 +7,39 @@ namespace Distribution.Features.Stock;
public class StockRepository(AppDBContext db) : IStockRepository
{
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
db.StockBalances
GetBalanceAsync(itemId, locationId, null, null);
// Looks at rows added earlier in the same unit of work first — they aren't in the database yet,
// so a second movement for the same (item, location, batch, serial) would otherwise insert a duplicate.
public async Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId)
{
var pending = db.StockBalances.Local.FirstOrDefault(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId);
if (pending is not null) return pending;
return await db.StockBalances
.Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId);
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId);
}
public async Task<List<StockBalance>> GetAllocatableBalancesAsync(Guid itemId, Guid locationId)
{
var balances = await db.StockBalances
.Include(b => b.Item)
.Include(b => b.ItemBatch)
.Include(b => b.ItemSerialNumber)
.Where(b => b.FK_ItemId == itemId && b.LocationId == locationId && b.Quantity > 0)
.ToListAsync();
// Tracked stock first: earliest expiry (FEFO), then oldest received serial (FIFO); untracked rows last.
return balances
.OrderBy(b => b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null ? 1 : 0)
.ThenBy(b => b.ItemBatch?.ExpiryDate ?? DateTime.MaxValue)
.ThenBy(b => b.ItemSerialNumber?.ReceivedAt ?? DateTime.MaxValue)
.ThenBy(b => b.UpdatedAt)
.ToList();
}
public Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
db.StockBalances
@@ -41,6 +71,17 @@ public class StockRepository(AppDBContext db) : IStockRepository
.Where(b => b.FK_ItemId == itemId)
.ToListAsync();
public Task<List<StockBalance>> GetLowStockAsync(Guid? locationId)
{
var query = db.StockBalances
.Include(b => b.Item)
.Where(b => b.LocationType == LocationType.Warehouse
&& b.Item.ReorderLevel != null
&& b.Quantity < b.Item.ReorderLevel);
if (locationId.HasValue) query = query.Where(b => b.LocationId == locationId.Value);
return query.OrderBy(b => b.Quantity).ToListAsync();
}
public async Task AddBalanceAsync(StockBalance balance) =>
await db.StockBalances.AddAsync(balance);
@@ -51,32 +92,111 @@ public class StockRepository(AppDBContext db) : IStockRepository
await db.StockMovements.AddAsync(movement);
public Task<StockMovement?> GetMovementByIdAsync(Guid id) =>
db.StockMovements.Include(m => m.Item).FirstOrDefaultAsync(m => m.Id == id);
db.StockMovements.Include(m => m.Item).Include(m => m.ItemBatch).Include(m => m.ItemSerialNumber).FirstOrDefaultAsync(m => m.Id == id);
public void UpdateMovement(StockMovement movement) =>
db.StockMovements.Update(movement);
public Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
{
var query = db.StockMovements.Include(m => m.Item).AsQueryable();
if (locationId.HasValue)
query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value);
if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value);
if (type.HasValue) query = query.Where(m => m.Type == type.Value);
if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value);
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
return query.OrderByDescending(m => m.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
public Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to)
private IQueryable<StockMovement> FilterMovements(MovementFilter f)
{
var query = db.StockMovements.AsQueryable();
if (locationId.HasValue)
query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value);
if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value);
if (type.HasValue) query = query.Where(m => m.Type == type.Value);
if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value);
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
return query.CountAsync();
if (f.LocationId.HasValue)
query = query.Where(m => m.FromLocationId == f.LocationId.Value || m.ToLocationId == f.LocationId.Value);
if (f.ItemId.HasValue) query = query.Where(m => m.FK_ItemId == f.ItemId.Value);
if (f.Type.HasValue) query = query.Where(m => m.Type == f.Type.Value);
if (f.ReferenceId.HasValue) query = query.Where(m => m.ReferenceId == f.ReferenceId.Value);
if (f.BatchId.HasValue) query = query.Where(m => m.FK_ItemBatchId == f.BatchId.Value);
if (f.SerialId.HasValue) query = query.Where(m => m.FK_ItemSerialNumberId == f.SerialId.Value);
if (f.CreatedBy.HasValue) query = query.Where(m => m.CreatedBy == f.CreatedBy.Value);
if (!string.IsNullOrWhiteSpace(f.ReasonCode)) query = query.Where(m => m.ReasonCode == f.ReasonCode);
if (f.From.HasValue) query = query.Where(m => m.CreatedAt >= f.From.Value);
if (f.To.HasValue)
{
// A bare date (midnight) means "through the end of that day".
var to = f.To.Value;
var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to;
query = query.Where(m => m.CreatedAt < toExclusive);
}
return query;
}
public Task<List<StockMovement>> GetMovementsAsync(MovementFilter filter, int page, int pageSize) =>
FilterMovements(filter)
.Include(m => m.Item)
.Include(m => m.ItemBatch)
.Include(m => m.ItemSerialNumber)
.OrderByDescending(m => m.CreatedAt)
.Skip((page - 1) * pageSize).Take(pageSize)
.ToListAsync();
public Task<int> GetMovementsCountAsync(MovementFilter filter) =>
FilterMovements(filter).CountAsync();
public async Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime toExclusive)
{
var rows = db.StockMovements.AsNoTracking()
.Where(m => m.FK_ItemId == itemId && (m.FromLocationId == locationId || m.ToLocationId == locationId));
var before = await rows.Where(m => m.CreatedAt < from)
.GroupBy(_ => 1)
.Select(g => g.Sum(m => m.ToLocationId == locationId ? m.Quantity : 0)
- g.Sum(m => m.FromLocationId == locationId ? m.Quantity : 0))
.FirstOrDefaultAsync();
var inRange = rows.Where(m => m.CreatedAt >= from && m.CreatedAt < toExclusive);
var received = await inRange.Where(m => m.ToLocationId == locationId).SumAsync(m => (decimal?)m.Quantity) ?? 0;
var issued = await inRange.Where(m => m.FromLocationId == locationId).SumAsync(m => (decimal?)m.Quantity) ?? 0;
var current = await db.StockBalances.AsNoTracking()
.Where(b => b.FK_ItemId == itemId && b.LocationId == locationId)
.SumAsync(b => (decimal?)b.Quantity) ?? 0;
return new MovementSummary(before, received, issued, before + received - issued, current);
}
public Task<List<StockMovement>> GetMovementsByReferenceAsync(Guid referenceId, Guid itemId, MovementType type) =>
db.StockMovements.AsNoTracking()
.Where(m => m.ReferenceId == referenceId && m.FK_ItemId == itemId && m.Type == type)
.ToListAsync();
public Task<Guid?> GetLastWarehouseSourceAsync(Guid vehicleId, Guid itemId) =>
db.StockMovements.AsNoTracking()
.Where(m => m.ToLocationId == vehicleId && m.FK_ItemId == itemId
&& m.FromLocationType == LocationType.Warehouse)
.OrderByDescending(m => m.CreatedAt)
.Select(m => m.FromLocationId)
.FirstOrDefaultAsync();
public Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber) =>
db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber);
public async Task AddItemBatchAsync(ItemBatch batch) =>
await db.ItemBatches.AddAsync(batch);
public Task<ItemSerialNumber?> GetSerialAsync(Guid serialId) =>
db.ItemSerialNumbers.FirstOrDefaultAsync(s => s.Id == serialId);
public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) =>
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
public async Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix)
{
// Only serials of the form <prefix><digits> count; anything else (a supplier's own serial that
// happens to start with the prefix) is ignored for numbering.
var serials = await db.ItemSerialNumbers
.AsNoTracking()
.Where(s => s.FK_ItemId == itemId && s.SerialNumber.StartsWith(prefix))
.Select(s => s.SerialNumber)
.ToListAsync();
var max = 0L;
foreach (var serial in serials)
{
var suffix = serial[prefix.Length..];
if (suffix.Length is > 0 and <= 9 && suffix.All(char.IsDigit) && long.TryParse(suffix, out var n))
max = Math.Max(max, n);
}
return (int)max + 1;
}
public async Task AddItemSerialNumberAsync(ItemSerialNumber serial) =>
await db.ItemSerialNumbers.AddAsync(serial);
}
+116 -104
View File
@@ -13,6 +13,7 @@ public class StockService(
ICategoryRepository categoryRepo,
IWarehouseRepository warehouseRepo,
IVehicleRepository vehicleRepo,
StockMover mover,
IUnitOfWork uow)
{
private static StockBalanceResponse MapBalance(StockBalance b, string locationName) =>
@@ -37,7 +38,9 @@ public class StockService(
private static StockMovementResponse MapMovement(StockMovement m, string? fromLocationName, string? toLocationName) =>
new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType,
m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode,
m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName);
m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName,
m.FK_ItemBatchId, m.ItemBatch?.BatchNumber, m.ItemBatch?.ExpiryDate,
m.FK_ItemSerialNumberId, m.ItemSerialNumber?.SerialNumber);
private async Task<StockMovementResponse> MapMovementAsync(StockMovement m)
{
@@ -87,6 +90,19 @@ public class StockService(
items.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
}
public async Task<List<LowStockItemResponse>> GetLowStockAsync(Guid? locationId)
{
var balances = await repo.GetLowStockAsync(locationId);
var names = await ResolveLocationNamesAsync(balances.Select(b => (b.LocationId, b.LocationType)));
return balances.Select(b => new LowStockItemResponse(
b.FK_ItemId, b.Item.Name, b.LocationId, names.GetValueOrDefault(b.LocationId, "—"),
b.Quantity, b.Item.ReorderLevel!.Value)).ToList();
}
/// <summary>The next unused sequence number for generating serials like "&lt;prefix&gt;0001" for an item.</summary>
public Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix) =>
repo.GetNextSerialSequenceAsync(itemId, prefix ?? string.Empty);
public async Task<StockTotalResponse?> GetItemTotalAsync(Guid itemId)
{
var item = await itemRepo.GetByIdAsync(itemId);
@@ -99,11 +115,16 @@ public class StockService(
balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
}
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync(
Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
public async Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime to)
{
var items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, to);
var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to;
return await repo.GetMovementSummaryAsync(itemId, locationId, from, toExclusive);
}
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync(MovementFilter filter, int page, int pageSize)
{
var items = await repo.GetMovementsAsync(filter, page, pageSize);
var total = await repo.GetMovementsCountAsync(filter);
var locations = new List<(Guid, LocationType)>();
foreach (var m in items)
@@ -124,60 +145,42 @@ public class StockService(
public async Task<StockMovementResponse> TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default)
{
await EnsureStockAsync(request.ItemId, request.FromLocationId, request.Quantity);
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = request.FromLocationId,
FromLocationType = request.FromLocationType,
ToLocationId = request.ToLocationId,
ToLocationType = request.ToLocationType,
Quantity = request.Quantity,
Type = MovementType.Transfer,
ReferenceId = request.ReferenceId,
CreatedBy = userId
};
var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
List<StockMovement> movements;
await uow.BeginAsync(ct);
try
{
await ApplyTransferAsync(request.ItemId, request.FromLocationId, request.ToLocationId, request.ToLocationType, request.Quantity);
await repo.AddMovementAsync(movement);
movements = await mover.MoveAsync(request.ItemId, item.Name,
request.FromLocationId, request.FromLocationType,
request.ToLocationId, request.ToLocationType,
request.Quantity, MovementType.Transfer, request.ReferenceId, userId);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return await MapMovementsAsync(movements, item, request.Quantity);
}
public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default)
{
await EnsureStockAsync(request.ItemId, request.LocationId, request.Quantity);
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = request.LocationId,
FromLocationType = request.LocationType,
ToLocationId = ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = request.Quantity,
Type = MovementType.Damage,
ReasonCode = request.ReasonCode,
CreatedBy = userId
};
var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
List<StockMovement> movements;
await uow.BeginAsync(ct);
try
{
await DebitBalanceAsync(request.ItemId, request.LocationId, request.Quantity);
await repo.AddMovementAsync(movement);
movements = await mover.MoveAsync(request.ItemId, item.Name,
request.LocationId, request.LocationType,
ExternalLocationId, LocationType.External,
request.Quantity, MovementType.Damage, null, userId, request.ReasonCode);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return await MapMovementsAsync(movements, item, request.Quantity);
}
public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default)
@@ -191,75 +194,89 @@ public class StockService(
if (!category.AllowCustomerReturn)
throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'.");
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.VehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = request.Quantity,
Type = MovementType.CustomerReturn,
ReferenceId = request.ReferenceId,
CreatedBy = userId
};
var tracked = item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate;
if (tracked && request.ReferenceId is null)
throw new InvalidOperationException(
$"'{item.Name}' is batch/serial tracked — a customer return must reference the ShopPO it was delivered on.");
List<StockMovement> movements;
await uow.BeginAsync(ct);
try
{
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity);
await repo.AddMovementAsync(movement);
if (tracked)
{
movements = await mover.ReturnToVehicleAsync(request.ItemId, item.Name, request.Quantity,
request.ReferenceId!.Value, userId);
}
else
{
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.VehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = request.Quantity,
Type = MovementType.CustomerReturn,
ReferenceId = request.ReferenceId,
CreatedBy = userId
};
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity);
await repo.AddMovementAsync(movement);
movements = [movement];
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return await MapMovementsAsync(movements, item, request.Quantity);
}
public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default)
{
var balance = await repo.GetBalanceAsync(request.ItemId, request.LocationId);
if (balance is not null && balance.Quantity + request.Quantity < 0)
throw new InvalidOperationException("Adjustment would result in negative stock.");
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = request.Quantity < 0 ? request.LocationId : ExternalLocationId,
FromLocationType = request.Quantity < 0 ? request.LocationType : LocationType.External,
ToLocationId = request.Quantity > 0 ? request.LocationId : ExternalLocationId,
ToLocationType = request.Quantity > 0 ? request.LocationType : LocationType.External,
Quantity = Math.Abs(request.Quantity),
Type = MovementType.Adjustment,
ReasonCode = request.ReasonCode,
CreatedBy = userId
};
var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
List<StockMovement> movements;
await uow.BeginAsync(ct);
try
{
if (balance is null)
if (request.Quantity < 0)
{
await repo.AddBalanceAsync(new StockBalance
{
FK_ItemId = request.ItemId,
LocationId = request.LocationId,
LocationType = request.LocationType,
Quantity = request.Quantity
});
movements = await mover.MoveAsync(request.ItemId, item.Name,
request.LocationId, request.LocationType,
ExternalLocationId, LocationType.External,
-request.Quantity, MovementType.Adjustment, null, userId, request.ReasonCode);
}
else
{
balance.Quantity += request.Quantity;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
// A positive adjustment can't say which batch/serial it adds — those enter stock through a GRN.
if (item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate)
throw new InvalidOperationException(
$"'{item.Name}' is batch/serial tracked — increase its stock by receiving a GRN, not by adjustment.");
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.LocationId,
ToLocationType = request.LocationType,
Quantity = request.Quantity,
Type = MovementType.Adjustment,
ReasonCode = request.ReasonCode,
CreatedBy = userId
};
await CreditBalanceAsync(request.ItemId, request.LocationId, request.LocationType, request.Quantity);
await repo.AddMovementAsync(movement);
movements = [movement];
}
await repo.AddMovementAsync(movement);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return await MapMovementsAsync(movements, item, Math.Abs(request.Quantity));
}
// Movements are an immutable ledger — only the reason/note can be edited, never quantity/locations/type.
@@ -269,28 +286,29 @@ public class StockService(
?? throw new InvalidOperationException("Movement not found.");
movement.ReasonCode = request.ReasonCode;
repo.UpdateMovement(movement);
await uow.SaveChangesAsync(ct);
await uow.BeginAsync(ct);
try
{
repo.UpdateMovement(movement);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
}
// ── Helpers ────────────────────────────────────────────────────────────
private async Task EnsureStockAsync(Guid itemId, Guid locationId, decimal quantity)
/// <summary>
/// One logical operation can produce several movements (one per batch/serial slice). The caller gets a
/// single response for the operation: the first slice's locations, with the total quantity moved.
/// </summary>
private async Task<StockMovementResponse> MapMovementsAsync(List<StockMovement> movements, Models.Item item, decimal totalQuantity)
{
var balance = await repo.GetBalanceAsync(itemId, locationId);
if (balance is null || balance.Quantity < quantity)
throw new InvalidOperationException("Insufficient stock at the specified location.");
}
private async Task DebitBalanceAsync(Guid itemId, Guid locationId, decimal quantity)
{
var balance = await repo.GetBalanceAsync(itemId, locationId)
?? throw new InvalidOperationException("No stock balance found.");
balance.Quantity -= quantity;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
var first = movements[0];
first.Item ??= item;
return (await MapMovementAsync(first)) with { Quantity = totalQuantity };
}
private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity)
@@ -313,10 +331,4 @@ public class StockService(
repo.UpdateBalance(balance);
}
}
private async Task ApplyTransferAsync(Guid itemId, Guid fromLocationId, Guid toLocationId, LocationType toLocationType, decimal quantity)
{
await DebitBalanceAsync(itemId, fromLocationId, quantity);
await CreditBalanceAsync(itemId, toLocationId, toLocationType, quantity);
}
}
@@ -0,0 +1,22 @@
namespace Distribution.Features.StockReport;
public record LedgerAggregate(
Guid ItemId, string Sku, string Name, string? Brand, string Category, string Uom,
decimal? CostPrice, decimal? ReorderLevel,
decimal Opening, decimal In, decimal Out, decimal Current, decimal WarehouseQty);
public record ExpiryAggregate(
Guid ItemId, string Sku, string Name, Guid BatchId, string? BatchNumber, DateTime ExpiryDate,
decimal Quantity, decimal? CostPrice, Guid LocationId, string LocationType, string LocationName);
public record OverviewAggregate(
int ActiveItems, int InStockItems, int LowStockItems, decimal TotalQty, decimal TotalValue,
int ExpiringSoonBatches, int ExpiredBatches);
public interface IStockReportRepository
{
Task<bool> IsWarehouseAsync(Guid locationId);
Task<OverviewAggregate> GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays);
Task<(List<LedgerAggregate> Rows, int Total)> GetLedgerAsync(StockReportFilter f, bool includeWarehouseLow, int page, int pageSize);
Task<(List<ExpiryAggregate> Rows, int Total)> GetExpiryAsync(Guid? locationId, string? search, DateTime cutoff, int page, int pageSize);
}
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.StockReport;
[ApiController]
[Route("api/reports/stock")]
public class StockReportController(StockReportService service) : ControllerBase
{
[HttpGet("overview")]
public async Task<IActionResult> Overview([FromQuery] Guid? locationId, [FromQuery] int expiryDays = 30)
{
var error = StockReportService.ValidateExpiry(expiryDays, 1, 1);
return error is null ? Ok(await service.GetOverviewAsync(locationId, expiryDays)) : BadRequest(new { message = error });
}
[HttpGet("ledger")]
public async Task<IActionResult> Ledger(
[FromQuery] Guid? locationId, [FromQuery] Guid? categoryId, [FromQuery] Guid? brandId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] string? status = null,
[FromQuery] bool activeOnly = true, [FromQuery] string? search = null,
[FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var end = (to ?? DateTime.Now).Date;
var start = (from ?? end.AddDays(-30)).Date;
var f = new StockReportFilter(locationId, categoryId, brandId, search, start, end, status, activeOnly);
var error = StockReportService.Validate(f, page, pageSize);
return error is null ? Ok(await service.GetLedgerAsync(f, page, pageSize)) : BadRequest(new { message = error });
}
[HttpGet("expiry")]
public async Task<IActionResult> Expiry(
[FromQuery] Guid? locationId, [FromQuery] string? search = null, [FromQuery] int days = 30,
[FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var error = StockReportService.ValidateExpiry(days, page, pageSize);
return error is null
? Ok(await service.GetExpiryAsync(locationId, search, days, page, pageSize))
: BadRequest(new { message = error });
}
}
@@ -0,0 +1,25 @@
namespace Distribution.Features.StockReport;
public static class StockStatus
{
public const string Ok = "OK";
public const string Low = "Low";
public const string Out = "Out";
}
public record StockReportFilter(
Guid? LocationId, Guid? CategoryId, Guid? BrandId, string? Search,
DateTime From, DateTime To, string? Status, bool ActiveOnly);
public record StockOverview(
int TotalSkus, decimal TotalQty, decimal TotalValue,
int LowStockCount, int OutOfStockCount, int ExpiringSoonCount, int ExpiredCount);
public record StockLedgerRow(
Guid ItemId, string Sku, string Name, string? Brand, string Category, string Uom,
decimal OpeningQty, decimal QtyIn, decimal QtyOut, decimal ClosingQty, decimal CurrentQty,
decimal? CostPrice, decimal StockValue, decimal? ReorderLevel, string Status);
public record StockExpiryRow(
Guid ItemId, string Sku, string Name, Guid BatchId, string? BatchNumber, DateTime ExpiryDate, int DaysLeft,
decimal Quantity, decimal? CostPrice, decimal Value, Guid LocationId, string LocationType, string LocationName);
@@ -0,0 +1,182 @@
using System.Linq.Expressions;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.StockReport;
public class StockReportRepository(AppDBContext db) : IStockReportRepository
{
// One location when picked, otherwise every internal location (warehouses + vehicles; External is not tracked).
private IQueryable<StockBalance> ScopedBalances(Guid? locationId)
{
var q = db.StockBalances.AsNoTracking().AsQueryable();
return locationId.HasValue
? q.Where(b => b.LocationId == locationId.Value)
: q.Where(b => b.LocationType == LocationType.Warehouse || b.LocationType == LocationType.Vehicle);
}
public Task<bool> IsWarehouseAsync(Guid locationId) =>
db.Warehouses.AsNoTracking().AnyAsync(w => w.Id == locationId);
public async Task<OverviewAggregate> GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays)
{
var balances = ScopedBalances(locationId);
var whBalances = balances.Where(b => b.LocationType == LocationType.Warehouse);
var perItem = db.Items.AsNoTracking().Where(i => i.IsActive).Select(i => new
{
i.ReorderLevel,
i.CostPrice,
Qty = balances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
Wh = whBalances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
});
var activeItems = await perItem.CountAsync();
var inStockItems = await perItem.CountAsync(x => x.Qty > 0);
var lowItems = includeWarehouseLow
? await perItem.CountAsync(x => x.Qty > 0 && x.ReorderLevel != null && x.Wh <= x.ReorderLevel)
: 0;
var totalQty = await perItem.SumAsync(x => (decimal?)x.Qty) ?? 0m;
var totalValue = await perItem.SumAsync(x => (decimal?)(x.Qty * (x.CostPrice ?? 0m))) ?? 0m;
var soonEnd = today.AddDays(expiryDays + 1);
var batchBalances = balances.Where(b => b.FK_ItemBatchId != null && b.Quantity > 0
&& b.ItemBatch!.ExpiryDate != null);
var expired = await batchBalances.Where(b => b.ItemBatch!.ExpiryDate < today)
.Select(b => b.FK_ItemBatchId).Distinct().CountAsync();
var soon = await batchBalances.Where(b => b.ItemBatch!.ExpiryDate >= today && b.ItemBatch.ExpiryDate < soonEnd)
.Select(b => b.FK_ItemBatchId).Distinct().CountAsync();
return new OverviewAggregate(activeItems, inStockItems, lowItems, totalQty, totalValue, soon, expired);
}
public async Task<(List<LedgerAggregate> Rows, int Total)> GetLedgerAsync(
StockReportFilter f, bool includeWarehouseLow, int page, int pageSize)
{
var balances = ScopedBalances(f.LocationId);
var whBalances = balances.Where(b => b.LocationType == LocationType.Warehouse);
var items = db.Items.AsNoTracking().AsQueryable();
if (f.ActiveOnly) items = items.Where(i => i.IsActive);
if (f.CategoryId.HasValue) items = items.Where(i => i.FK_CategoryId == f.CategoryId.Value);
if (f.BrandId.HasValue) items = items.Where(i => i.FK_BrandId == f.BrandId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
items = items.Where(i => i.Name.Contains(s) || i.Sku.Contains(s));
}
var withQty = items.Select(i => new
{
Item = i,
Qty = balances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
Wh = whBalances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m,
});
if (f.Status == "out")
withQty = withQty.Where(x => x.Qty <= 0);
else if (f.Status == "low")
withQty = includeWarehouseLow
? withQty.Where(x => x.Qty > 0 && x.Item.ReorderLevel != null && x.Wh <= x.Item.ReorderLevel)
: withQty.Where(x => false);
var total = await withQty.CountAsync();
var pageRows = await withQty
.OrderBy(x => x.Item.Name)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new
{
x.Item.Id, x.Item.Sku, x.Item.Name,
Brand = x.Item.Brand != null ? x.Item.Brand.Name : null,
Category = x.Item.Category.Name,
Uom = x.Item.BaseUom.Name,
x.Item.CostPrice, x.Item.ReorderLevel,
x.Qty, x.Wh,
})
.ToListAsync();
var ids = pageRows.Select(r => r.Id).ToList();
var toExclusive = f.To.TimeOfDay == TimeSpan.Zero ? f.To.AddDays(1) : f.To;
var from = f.From;
Expression<Func<StockMovement, bool>> isIn, isOut;
if (f.LocationId.HasValue)
{
var loc = f.LocationId.Value;
isIn = m => m.ToLocationId == loc;
isOut = m => m.FromLocationId == loc;
}
else
{
// All locations: only movements that cross the boundary of the internal network count;
// warehouse <-> vehicle transfers net to zero and are left out of in/out.
isIn = m => m.ToLocationId != null && m.ToLocationType != LocationType.External
&& (m.FromLocationId == null || m.FromLocationType == LocationType.External);
isOut = m => m.FromLocationId != null && m.FromLocationType != LocationType.External
&& (m.ToLocationId == null || m.ToLocationType == LocationType.External);
}
var moves = db.StockMovements.AsNoTracking().Where(m => ids.Contains(m.FK_ItemId) && m.CreatedAt < toExclusive);
var before = moves.Where(m => m.CreatedAt < from);
var inRange = moves.Where(m => m.CreatedAt >= from);
var openingIn = await SumByItem(before, isIn);
var openingOut = await SumByItem(before, isOut);
var rangeIn = await SumByItem(inRange, isIn);
var rangeOut = await SumByItem(inRange, isOut);
var rows = pageRows.Select(r => new LedgerAggregate(
r.Id, r.Sku, r.Name, r.Brand, r.Category, r.Uom, r.CostPrice, r.ReorderLevel,
openingIn.GetValueOrDefault(r.Id) - openingOut.GetValueOrDefault(r.Id),
rangeIn.GetValueOrDefault(r.Id), rangeOut.GetValueOrDefault(r.Id),
r.Qty, r.Wh)).ToList();
return (rows, total);
}
private static async Task<Dictionary<Guid, decimal>> SumByItem(
IQueryable<StockMovement> q, Expression<Func<StockMovement, bool>> predicate) =>
await q.Where(predicate)
.GroupBy(m => m.FK_ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(m => m.Quantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Qty);
public async Task<(List<ExpiryAggregate> Rows, int Total)> GetExpiryAsync(
Guid? locationId, string? search, DateTime cutoffExclusive, int page, int pageSize)
{
var q = ScopedBalances(locationId)
.Where(b => b.FK_ItemBatchId != null && b.Quantity > 0
&& b.ItemBatch!.ExpiryDate != null && b.ItemBatch.ExpiryDate < cutoffExclusive);
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim();
q = q.Where(b => b.Item.Name.Contains(s) || b.Item.Sku.Contains(s));
}
var total = await q.CountAsync();
var page_ = await q
.OrderBy(b => b.ItemBatch!.ExpiryDate).ThenBy(b => b.Item.Name)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(b => new
{
b.FK_ItemId, b.Item.Sku, b.Item.Name,
BatchId = b.FK_ItemBatchId!.Value,
b.ItemBatch!.BatchNumber,
ExpiryDate = b.ItemBatch.ExpiryDate!.Value,
b.Quantity, b.Item.CostPrice, b.LocationId, b.LocationType,
})
.ToListAsync();
var locIds = page_.Select(r => r.LocationId).Distinct().ToList();
var names = await db.Warehouses.AsNoTracking().Where(w => locIds.Contains(w.Id))
.Select(w => new { w.Id, w.Name }).ToDictionaryAsync(w => w.Id, w => w.Name);
var plates = await db.Vehicles.AsNoTracking().Where(v => locIds.Contains(v.Id))
.Select(v => new { v.Id, v.NumberPlate }).ToDictionaryAsync(v => v.Id, v => v.NumberPlate);
var rows = page_.Select(r => new ExpiryAggregate(
r.FK_ItemId, r.Sku, r.Name, r.BatchId, r.BatchNumber, r.ExpiryDate,
r.Quantity, r.CostPrice, r.LocationId, r.LocationType.ToString(),
names.GetValueOrDefault(r.LocationId) ?? plates.GetValueOrDefault(r.LocationId) ?? "—")).ToList();
return (rows, total);
}
}
@@ -0,0 +1,65 @@
using Distribution.Features.Stock;
namespace Distribution.Features.StockReport;
public class StockReportService(IStockReportRepository repo)
{
public const int MaxRangeDays = 366;
public const int MaxExpiryDays = 3650;
public static string? Validate(StockReportFilter f, int page, int pageSize)
{
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
if (f.To < f.From) return "'to' must not be before 'from'.";
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
if (f.Status is not (null or "low" or "out")) return "status must be 'low' or 'out'.";
return null;
}
public static string? ValidateExpiry(int days, int page, int pageSize)
{
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
if (days < 0 || days > MaxExpiryDays) return $"days must be between 0 and {MaxExpiryDays}.";
return null;
}
// Low-stock is judged on warehouse quantity, so it only applies when the scope includes warehouses.
private async Task<bool> IncludesWarehouses(Guid? locationId) =>
!locationId.HasValue || await repo.IsWarehouseAsync(locationId.Value);
public async Task<StockOverview> GetOverviewAsync(Guid? locationId, int expiryDays)
{
var agg = await repo.GetOverviewAsync(locationId, await IncludesWarehouses(locationId), DateTime.Now.Date, expiryDays);
return new StockOverview(agg.InStockItems, agg.TotalQty, agg.TotalValue, agg.LowStockItems,
agg.ActiveItems - agg.InStockItems, agg.ExpiringSoonBatches, agg.ExpiredBatches);
}
public async Task<PagedResponse<StockLedgerRow>> GetLedgerAsync(StockReportFilter f, int page, int pageSize)
{
var includeLow = await IncludesWarehouses(f.LocationId);
var (rows, total) = await repo.GetLedgerAsync(f, includeLow, page, pageSize);
var mapped = rows.Select(r =>
{
var status = r.Current <= 0 ? StockStatus.Out
: includeLow && r.ReorderLevel != null && r.WarehouseQty <= r.ReorderLevel ? StockStatus.Low
: StockStatus.Ok;
return new StockLedgerRow(r.ItemId, r.Sku, r.Name, r.Brand, r.Category, r.Uom,
r.Opening, r.In, r.Out, r.Opening + r.In - r.Out, r.Current,
r.CostPrice, r.Current * (r.CostPrice ?? 0m), r.ReorderLevel, status);
}).ToList();
return new PagedResponse<StockLedgerRow>(page, pageSize, total, mapped);
}
public async Task<PagedResponse<StockExpiryRow>> GetExpiryAsync(
Guid? locationId, string? search, int days, int page, int pageSize)
{
var today = DateTime.Now.Date;
var (rows, total) = await repo.GetExpiryAsync(locationId, search, today.AddDays(days + 1), page, pageSize);
var mapped = rows.Select(r => new StockExpiryRow(
r.ItemId, r.Sku, r.Name, r.BatchId, r.BatchNumber, r.ExpiryDate,
(int)(r.ExpiryDate.Date - today).TotalDays,
r.Quantity, r.CostPrice, r.Quantity * (r.CostPrice ?? 0m),
r.LocationId, r.LocationType, r.LocationName)).ToList();
return new PagedResponse<StockExpiryRow>(page, pageSize, total, mapped);
}
}
@@ -7,10 +7,11 @@ public interface IStoreRepository
Task<Models.Store?> GetByIdAsync(Guid id);
Task<Models.Store?> GetByIdWithRegionAsync(Guid id);
Task<List<Models.Store>> GetAllAsync();
Task<List<Models.Store>> GetAllAsync(Guid? routeId, KycStatus? kycStatus, string? search, int page, int pageSize);
Task<int> GetCountAsync(Guid? routeId, KycStatus? kycStatus, string? search);
Task<List<Models.Store>> GetAllAsync(Guid? routeId, string? search, int page, int pageSize);
Task<int> GetCountAsync(Guid? routeId, string? search);
Task AddAsync(Models.Store entity);
void Update(Models.Store entity);
void Remove(Models.Store entity);
Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
Task<List<Models.Invoice>> GetInvoicesForAnalyticsAsync(Guid storeId);
}
@@ -1,7 +0,0 @@
namespace Distribution.Features.Store.Kyc;
public interface IStoreKycDocumentRepository
{
Task<List<Models.StoreKycDocument>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.StoreKycDocument entity);
}
@@ -1,22 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store.Kyc;
[ApiController]
[Route("api/stores/{storeId:guid}/kyc-documents")]
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetByStoreId(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
{
var result = await service.UploadAsync(storeId, request, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -1,5 +0,0 @@
namespace Distribution.Features.Store.Kyc;
public record UploadKycDocumentRequest(string DocumentType, IFormFile File);
public record StoreKycDocumentResponse(Guid Id, Guid StoreId, string DocumentType, string FileReference, DateTime UploadedAt);
@@ -1,13 +0,0 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Store.Kyc;
public class StoreKycDocumentRepository(AppDBContext context) : IStoreKycDocumentRepository
{
public async Task<List<Models.StoreKycDocument>> GetByStoreIdAsync(Guid storeId) =>
await context.StoreKycDocuments.Where(d => d.FK_StoreId == storeId && d.IsActive).ToListAsync();
public async Task AddAsync(Models.StoreKycDocument entity) =>
await context.StoreKycDocuments.AddAsync(entity);
}
@@ -1,71 +0,0 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Store.Kyc;
public class StoreKycDocumentService(
IStoreKycDocumentRepository repo,
IStoreRepository storeRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IWebHostEnvironment env)
{
private const int GlobalScopeRoleLevel = 50;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private static StoreKycDocumentResponse ToResponse(Models.StoreKycDocument d) =>
new(d.Id, d.FK_StoreId, d.DocumentType, d.FileReference, d.UploadedAt);
public async Task<List<StoreKycDocumentResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var docs = await repo.GetByStoreIdAsync(storeId);
return docs.Select(ToResponse).ToList();
}
public async Task<StoreKycDocumentResponse?> UploadAsync(Guid storeId, UploadKycDocumentRequest request, CancellationToken ct = default)
{
var store = await storeRepo.GetByIdAsync(storeId);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(storeId)) return null;
var uploadsRoot = Path.Combine(env.ContentRootPath, "wwwroot", "uploads", "kyc", storeId.ToString());
Directory.CreateDirectory(uploadsRoot);
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(request.File.FileName)}";
var filePath = Path.Combine(uploadsRoot, fileName);
await using (var stream = new FileStream(filePath, FileMode.Create))
{
await request.File.CopyToAsync(stream, ct);
}
var fileReference = $"uploads/kyc/{storeId}/{fileName}";
var doc = new Models.StoreKycDocument
{
FK_StoreId = storeId,
DocumentType = request.DocumentType,
FileReference = fileReference,
UploadedAt = DateTime.Now
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(doc);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(doc);
}
}
+27 -14
View File
@@ -1,29 +1,39 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store;
[ApiController]
[Route("api/stores")]
public class StoreController(StoreService service) : ControllerBase
public class StoreController(
StoreService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("store.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? routeId,
[FromQuery] KycStatus? kycStatus,
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.GetAllAsync(routeId, kycStatus, search, page, pageSize));
Ok(await service.GetAllAsync(routeId, search, page, pageSize));
[HttpGet("{id:guid}")]
[Authorize("store.view")]
public async Task<IActionResult> GetById(Guid id)
{
var userId = currentUser.UserId;
//if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
// return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize("store.manage")]
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -31,6 +41,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("store.manage")]
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,6 +49,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("store.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -45,6 +57,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/credit-limit")]
[Authorize("store.manage")]
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
{
var result = await service.UpdateCreditLimitAsync(id, request, ct);
@@ -52,6 +65,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/outstanding-balance")]
[Authorize("store.manage")]
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
{
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
@@ -59,20 +73,15 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/outstanding-balance")]
[Authorize("store.view")]
public async Task<IActionResult> GetOutstandingBalance(Guid id)
{
var result = await service.GetOutstandingBalanceAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/kyc/verify")]
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
{
var result = await service.VerifyKycAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/segment")]
[Authorize("store.manage")]
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
{
var result = await service.ClassifySegmentAsync(id, request, ct);
@@ -80,6 +89,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/visit-history")]
[Authorize("store.view")]
public async Task<IActionResult> GetVisitHistory(Guid id)
{
var result = await service.GetVisitHistoryAsync(id);
@@ -87,6 +97,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
[Authorize("store.view")]
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
{
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
@@ -94,20 +105,22 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/dashboard")]
[Authorize("store.view")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
[HttpPatch("{id:guid}/kyc-status")]
public async Task<IActionResult> PatchKycStatus(Guid id, PatchKycStatusRequest request, CancellationToken ct)
[HttpGet("{id:guid}/analytics")]
public async Task<IActionResult> GetAnalytics(Guid id, [FromQuery] int months = 12, [FromQuery] int overdueDays = 30)
{
var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct);
var result = await service.GetAnalyticsAsync(id, months, overdueDays);
return result is null ? NotFound() : Ok(result);
}
[HttpPatch("{id:guid}/status")]
[Authorize("store.manage")]
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
{
var result = await service.SetActiveStatusAsync(id, request.IsActive, ct);
+55 -18
View File
@@ -4,29 +4,34 @@ namespace Distribution.Features.Store;
public record CreateStoreRequest(
Guid RouteId,
string OwnerName,
string StoreName,
string StoreAddress,
decimal Latitude,
decimal Longitude,
decimal OneTimeAmount,
decimal CreditAmount);
public record UpdateStoreRequest(
Guid RouteId,
string OwnerName,
string? OwnerName,
string StoreName,
string StoreAddress,
decimal Latitude,
decimal Longitude,
decimal OneTimeAmount,
decimal CreditAmount,
bool IsActive = true);
decimal CreditLimit = 0,
string? ContactNumber = null);
public record UpdateStoreRequest(
Guid RouteId,
string? OwnerName,
string StoreName,
string StoreAddress,
decimal Latitude,
decimal Longitude,
decimal OneTimeAmount,
decimal CreditAmount,
bool IsActive = true,
decimal? CreditLimit = null,
string? ContactNumber = null);
public record StoreResponse(
Guid Id,
Guid RouteId,
string OwnerName,
string? OwnerName,
string? ContactNumber,
string StoreName,
string StoreAddress,
decimal Latitude,
@@ -36,7 +41,6 @@ public record StoreResponse(
decimal CreditLimit,
decimal OutstandingBalance,
string? Segment,
KycStatus KycStatus,
bool IsActive,
DateTime CreatedAt);
@@ -44,8 +48,6 @@ public record UpdateCreditLimitRequest(decimal NewLimit, Guid ApproverId);
public record UpdateOutstandingBalanceRequest(decimal NewBalance);
public record VerifyKycRequest(KycStatus Status, string? Notes);
public record ClassifySegmentRequest(string Segment);
public record OutstandingBalanceResponse(Guid StoreId, decimal CreditLimit, decimal OutstandingBalance, decimal AvailableCredit);
@@ -54,8 +56,43 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
public record PatchKycStatusRequest(KycStatus KycStatus);
public record PatchStoreStatusRequest(bool IsActive);
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
public record StoreAnalyticsSummary(
decimal TotalSales,
decimal TotalCollected,
decimal Outstanding,
decimal OverdueAmount,
int OverdueInvoiceCount,
int InvoiceCount,
decimal AverageOrderValue,
DateTime? LastSaleDate,
decimal CreditLimit,
decimal CreditUtilizationPercent);
public record StoreMonthlySales(string Month, decimal Invoiced, decimal Collected, decimal Outstanding);
public record StoreTopProduct(Guid ItemId, string Name, int Quantity, decimal Revenue, DateTime LastSoldAt);
public record StoreAgeingBucket(string Label, decimal Amount, int InvoiceCount);
public record StoreAnalyticsInvoice(
Guid InvoiceId,
DateTime InvoiceDate,
DateTime DueDate,
decimal TotalAmount,
decimal PaidAmount,
decimal Balance,
string PaymentStatus,
int DaysOverdue);
public record StoreAnalyticsResponse(
Guid StoreId,
int OverdueDays,
StoreAnalyticsSummary Summary,
List<StoreMonthlySales> MonthlySales,
List<StoreTopProduct> TopProducts,
List<StoreAgeingBucket> Ageing,
List<StoreAnalyticsInvoice> Invoices);
+14 -7
View File
@@ -18,23 +18,22 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
await context.Stores.ToListAsync();
private static IQueryable<Models.Store> ApplyFilters(
IQueryable<Models.Store> query, Guid? routeId, KycStatus? kycStatus, string? search) =>
IQueryable<Models.Store> query, Guid? routeId, string? search) =>
query
.Where(s => routeId == null || s.FK_RouteId == routeId)
.Where(s => kycStatus == null || s.KycStatus == kycStatus)
.Where(s => string.IsNullOrWhiteSpace(search)
|| s.StoreName.Contains(search)
|| s.OwnerName.Contains(search));
|| (s.OwnerName != null && s.OwnerName.Contains(search)));
public async Task<List<Models.Store>> GetAllAsync(Guid? routeId, KycStatus? kycStatus, string? search, int page, int pageSize) =>
await ApplyFilters(context.Stores, routeId, kycStatus, search)
public async Task<List<Models.Store>> GetAllAsync(Guid? routeId, string? search, int page, int pageSize) =>
await ApplyFilters(context.Stores, routeId, search)
.OrderBy(s => s.StoreName)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? routeId, KycStatus? kycStatus, string? search) =>
await ApplyFilters(context.Stores, routeId, kycStatus, search).CountAsync();
public async Task<int> GetCountAsync(Guid? routeId, string? search) =>
await ApplyFilters(context.Stores, routeId, search).CountAsync();
public async Task AddAsync(Models.Store entity) =>
await context.Stores.AddAsync(entity);
@@ -52,4 +51,12 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
await context.ShopPOs
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive)
.ToListAsync();
public async Task<List<Models.Invoice>> GetInvoicesForAnalyticsAsync(Guid storeId) =>
await context.Invoices
.AsNoTracking()
.Include(i => i.Payments)
.Include(i => i.ShopPO).ThenInclude(p => p.Items).ThenInclude(x => x.Item)
.Where(i => i.IsActive && i.Status != InvoiceStatus.Draft && i.ShopPO.FK_StoreId == storeId)
.ToListAsync();
}
+101 -30
View File
@@ -11,7 +11,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasGlobalScopeAsync() => await currentUser.GetRoleLevelAsync() >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
@@ -21,16 +21,16 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
}
private static StoreResponse ToResponse(Models.Store s) =>
new(s.Id, s.FK_RouteId, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount,
s.CreditLimit, s.OutstandingBalance, s.Segment, s.KycStatus, s.IsActive, s.CreatedAt);
new(s.Id, s.FK_RouteId, s.OwnerName, s.ContactNumber, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount,
s.CreditLimit, s.OutstandingBalance, s.Segment, s.IsActive, s.CreatedAt);
public async Task<PagedResponse<StoreResponse>> GetAllAsync(
Guid? routeId, KycStatus? kycStatus, string? search, int page, int pageSize)
Guid? routeId, string? search, int page, int pageSize)
{
// Deactivated stores stay visible in the list showing Inactive — deactivate never
// removes the row, only marks it (same rule as Orders, see testbug.md #42).
var total = await repo.GetCountAsync(routeId, kycStatus, search);
var stores = await repo.GetAllAsync(routeId, kycStatus, search, page, pageSize);
var total = await repo.GetCountAsync(routeId, search);
var stores = await repo.GetAllAsync(routeId, search, page, pageSize);
return new PagedResponse<StoreResponse>(page, pageSize, total, stores.Select(ToResponse).ToList());
}
@@ -48,12 +48,14 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
{
FK_RouteId = request.RouteId,
OwnerName = request.OwnerName,
ContactNumber = request.ContactNumber,
StoreName = request.StoreName,
StoreAddress = request.StoreAddress,
Latitude = request.Latitude,
Longitude = request.Longitude,
OneTimeAmount = request.OneTimeAmount,
CreditAmount = request.CreditAmount
CreditAmount = request.CreditAmount,
CreditLimit = request.CreditLimit
};
await uow.BeginAsync(ct);
try
@@ -76,12 +78,14 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
if (!await HasStoreScopeAsync(id)) return null;
store.FK_RouteId = request.RouteId;
store.OwnerName = request.OwnerName;
store.ContactNumber = request.ContactNumber;
store.StoreName = request.StoreName;
store.StoreAddress = request.StoreAddress;
store.Latitude = request.Latitude;
store.Longitude = request.Longitude;
store.OneTimeAmount = request.OneTimeAmount;
store.CreditAmount = request.CreditAmount;
if (request.CreditLimit is not null) store.CreditLimit = request.CreditLimit.Value;
store.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try
@@ -118,7 +122,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
public async Task<StoreResponse?> UpdateCreditLimitAsync(Guid id, UpdateCreditLimitRequest request, CancellationToken ct = default)
{
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
@@ -139,7 +143,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
public async Task<StoreResponse?> UpdateOutstandingBalanceAsync(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct = default)
{
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
@@ -165,27 +169,6 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
if (!await HasStoreScopeAsync(id)) return null;
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, store.OutstandingBalance, store.CreditLimit - store.OutstandingBalance);
}
public async Task<StoreResponse?> VerifyKycAsync(Guid id, VerifyKycRequest request, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.KycStatus = request.Status;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<StoreResponse?> ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
@@ -270,4 +253,92 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt));
}
public async Task<StoreAnalyticsResponse?> GetAnalyticsAsync(Guid id, int months, int overdueDays)
{
var store = await repo.GetByIdAsync(id);
if (store is null) return null;
if (!await HasStoreScopeAsync(id)) return null;
months = Math.Clamp(months, 1, 36);
overdueDays = Math.Clamp(overdueDays, 0, 365);
var now = DateTime.Now;
var from = new DateTime(now.Year, now.Month, 1).AddMonths(-(months - 1));
var invoices = (await repo.GetInvoicesForAnalyticsAsync(id))
.Select(i =>
{
var date = i.FinalizedAt ?? i.CreatedAt;
var paid = i.Payments.Where(p => p.IsActive).Sum(p => p.Amount);
var balance = Math.Max(0, i.TotalAmount - paid);
var due = date.AddDays(overdueDays);
var daysOverdue = balance > 0 && now > due ? (int)(now - due).TotalDays : 0;
return (Invoice: i, Date: date, Due: due, Paid: paid, Balance: balance, DaysOverdue: daysOverdue);
})
.ToList();
var inRange = invoices.Where(x => x.Date >= from).ToList();
var totalSales = inRange.Sum(x => x.Invoice.TotalAmount);
var overdue = invoices.Where(x => x.DaysOverdue > 0).ToList();
var summary = new StoreAnalyticsSummary(
totalSales,
inRange.Sum(x => x.Paid),
invoices.Sum(x => x.Balance),
overdue.Sum(x => x.Balance),
overdue.Count,
inRange.Count,
inRange.Count == 0 ? 0 : Math.Round(totalSales / inRange.Count, 2),
invoices.Count == 0 ? null : invoices.Max(x => x.Date),
store.CreditLimit,
store.CreditLimit > 0 ? Math.Round(store.OutstandingBalance / store.CreditLimit * 100, 1) : 0);
var monthly = Enumerable.Range(0, months)
.Select(n => from.AddMonths(n))
.Select(m => new StoreMonthlySales(
m.ToString("yyyy-MM"),
inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Invoice.TotalAmount),
inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Paid),
invoices
.Where(x => x.Date < m.AddMonths(1))
.Sum(x => Math.Max(0, x.Invoice.TotalAmount - x.Invoice.Payments.Where(p => p.IsActive && p.CollectedAt < m.AddMonths(1)).Sum(p => p.Amount)))))
.ToList();
var topProducts = inRange
.SelectMany(x => x.Invoice.ShopPO.Items.Select(it => (it, x.Date)))
.GroupBy(t => t.it.FK_ItemId)
.Select(g => new StoreTopProduct(
g.Key,
g.First().it.Item?.Name ?? "Unknown",
g.Sum(t => t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity),
g.Sum(t => (t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity) * t.it.UnitPrice),
g.Max(t => t.Date)))
.OrderByDescending(p => p.Revenue)
.Take(10)
.ToList();
var unpaid = invoices.Where(x => x.Balance > 0).ToList();
StoreAgeingBucket Bucket(string label, Func<int, bool> match)
{
var items = unpaid.Where(x => match(x.DaysOverdue)).ToList();
return new StoreAgeingBucket(label, items.Sum(x => x.Balance), items.Count);
}
var ageing = new List<StoreAgeingBucket>
{
Bucket("Current", d => d <= 0),
Bucket("1-30", d => d is >= 1 and <= 30),
Bucket("31-60", d => d is >= 31 and <= 60),
Bucket("61-90", d => d is >= 61 and <= 90),
Bucket("90+", d => d > 90)
};
var list = invoices
.OrderByDescending(x => x.Date)
.Select(x => new StoreAnalyticsInvoice(
x.Invoice.Id, x.Date, x.Due, x.Invoice.TotalAmount, x.Paid, x.Balance,
x.Invoice.PaymentStatus.ToString(), x.DaysOverdue))
.ToList();
return new StoreAnalyticsResponse(id, overdueDays, summary, monthly, topProducts, ageing, list);
}
}
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SupplierClaims;
@@ -7,10 +8,12 @@ namespace Distribution.Features.SupplierClaims;
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/supplier-claims")]
[Authorize("supplier-claim.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/supplier-claims/{id:guid}")]
[Authorize("supplier-claim.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,10 +21,12 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpGet("api/purchase-orders/{id:guid}/claims")]
[Authorize("supplier-claim.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/claims")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Create(Guid id, CreateSupplierClaimRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -37,6 +42,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPut("api/supplier-claims/{id:guid}")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Update(Guid id, UpdateSupplierClaimRequest request, CancellationToken ct)
{
try
@@ -48,6 +54,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPost("api/supplier-claims/{id:guid}/approve")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ApprovedByUserId;
@@ -62,6 +69,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPost("api/supplier-claims/{id:guid}/reject")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.RejectedByUserId;
@@ -1,5 +1,6 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SysConfig;
@@ -9,11 +10,12 @@ namespace Distribution.Features.SysConfig;
public class SysConfigController(SysConfigService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("sys-config.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
// Typed shortcut — must be declared before {key} to take precedence
[HttpGet("invoice-creation-mode")]
[Authorize("sys-config.view")]
public async Task<IActionResult> GetInvoiceCreationMode()
{
var mode = await service.GetInvoiceCreationModeAsync();
@@ -21,6 +23,7 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
}
[HttpPut("invoice-creation-mode")]
[Authorize("sys-config.manage")]
public async Task<IActionResult> SetInvoiceCreationMode(SetInvoiceCreationModeRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -33,6 +36,7 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
}
[HttpGet("{key}")]
[Authorize("sys-config.view")]
public async Task<IActionResult> GetByKey(string key)
{
var result = await service.GetByKeyAsync(key);
@@ -40,12 +44,14 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
}
[HttpPut("{key}")]
[Authorize("sys-config.manage")]
public async Task<IActionResult> Set(string key, SetSysConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var userId = currentUser.UserId ?? request.UserId;
if (userId is null) return BadRequest(new { error = "UserId is required while auth is unavailable" });
try
{
var result = await service.SetAsync(key, request.Value, userId, ct);
var result = await service.SetAsync(key, request.Value, userId.Value, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }

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