Compare commits

..

30 Commits

Author SHA1 Message Date
Kalana B. Thilakarathna cefefcbc49 md update 2026-08-24 21:42:13 +05:30
Kalana B. Thilakarathna 6d8b6b3e59 Add DeliveryTask, Payment, and Sale features
Implemented DeliveryTask, Payment, and Sale entities with full CRUD, business logic, and API endpoints. Updated Order/OrderItem for sale linkage and fulfillment tracking. Registered new DbSets and DI services. Revised documentation for delivery, sale, and payment workflows.
2026-08-24 21:34:57 +05:30
DeepnaPooja a833586e83 Merge pull request 'Feat/procurement' (#8) from feat/procurement into dev
Reviewed-on: #8
2026-08-24 11:28:48 +00:00
DeepnaPooja 3bc33ce08f Update procurement and master plan docs to match implementation 2026-08-24 16:52:26 +05:30
DeepnaPooja 870b412a06 Check stock availability when creating an order 2026-08-24 16:52:20 +05:30
DeepnaPooja 095d35e1b8 Add supplier evaluation and block delete when referenced by a purchase order 2026-08-24 16:52:12 +05:30
DeepnaPooja d7b03941fb Wire up procurement services in DI 2026-08-24 16:52:06 +05:30
DeepnaPooja 81a49e1a98 Add purchase return feature (returns received stock to a supplier) 2026-08-24 16:51:59 +05:30
DeepnaPooja 2995ce7012 Add goods receipt feature (creates stock from a purchase order) 2026-08-24 16:51:52 +05:30
DeepnaPooja db0850f1f0 Add purchase order feature (create/update/close/cancel/match-invoice) 2026-08-24 16:51:44 +05:30
DeepnaPooja 702dbeb39f Add procurement tables migration 2026-08-24 16:51:37 +05:30
DeepnaPooja c8d23afe59 Add procurement domain models (PurchaseOrder, GoodsReceipt, PurchaseReturn) 2026-08-24 16:51:22 +05:30
Kalana B. Thilakarathna ac1b2fbc4b loading event 2026-08-24 11:23:32 +05:30
Kalana B. Thilakarathna f381e7a634 Update docs: revise pending tasks and mark step 12 done
- 03_ride_lifecycle.md: Updated pending tasks note to only mention tests for new services, removing reference to database migrations.
- mainPlan.md: Changed status of "Store Management (Beyond CRUD)" (step 12) from "In Progress" to "Done".
2026-08-22 11:16:38 +05:30
DeepnaPooja 6a0322570b Merge pull request 'Feat/promotions & trade' (#7) from feat/promotions-&-trade into dev
Reviewed-on: #7
2026-08-21 08:51:48 +00:00
DeepnaPooja dc4b1934e1 Wire store scheme eligibility and update docs 2026-08-21 14:14:14 +05:30
DeepnaPooja aabace1bba Add scheme APIs and application services 2026-08-21 14:14:07 +05:30
DeepnaPooja f7101800cd Add scheme data model and migrations 2026-08-21 14:10:40 +05:30
Kalana B. Thilakarathna c9f5006727 03_ride_lifecycle implemented and not tested 2026-08-21 02:08:11 +05:30
DeepnaPooja 942a39b0eb Merge pull request 'Feat/pricing & supplier' (#5) from feat/pricing_&_supplier into dev
Reviewed-on: #5
2026-08-20 12:08:05 +00:00
DeepnaPooja b2681d1236 Update pricing and store implementation plans 2026-08-20 17:29:11 +05:30
DeepnaPooja 63bc6e9cd8 Add pricing and supplier master data 2026-08-20 17:28:55 +05:30
DeepnaPooja 7a2494c156 Merge pull request 'Feat/store management' (#3) from feat/store_management into dev
Reviewed-on: #3
2026-08-20 08:13:40 +00:00
DeepnaPooja e3e331a2bb fix the running issue 2026-08-20 13:24:51 +05:30
DeepnaPooja 4e73dd7fb1 update store mngt.md 2026-08-20 13:10:46 +05:30
DeepnaPooja e88668beca Add store management workflows and KYC order gating 2026-08-20 13:08:00 +05:30
DeepnaPooja c1961ebea2 Add store KYC document management 2026-08-20 13:07:47 +05:30
DeepnaPooja cf9d2c2661 Add store credit and KYC data model 2026-08-20 13:07:27 +05:30
Kalana B. Thilakarathna 429a80b1ed Add planning docs for steps 10–23 and update roadmap
Added detailed implementation planning documents for steps 10–23, covering all major feature areas. Updated mainPlan.md to reference these steps, their dependencies, and planning files. No code changes—these documents clarify requirements and guide future development.
2026-08-20 10:04:09 +05:30
DeepnaPooja d2390e7b12 Add md file features in region, route, store, fleet, pricing, procurement, SFA, promotions, returns, finance, reporting, notifications, integration, and mobile capabilities 2026-08-19 15:18:31 +05:30
175 changed files with 17483 additions and 4378 deletions
@@ -0,0 +1 @@
False
@@ -0,0 +1 @@
a3dd8e4b-8c69-4536-b080-24506bc95dea
@@ -0,0 +1 @@
614d490d-41ba-47f9-ae3a-b56948b3d6e4
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTS2/jIBD+Kxbn4GKMMfatbVJtVHUbtdHuam8wjBtUF0cY96Eq/32Fk6rbve0FoREz8714J14/IWnJ+X6/ekYfR7Ig0c0lzrikTFHOtky1jLeszouS84pVv8mCuGt8Iy2pBUABDdISJVJhWUVN2QG1FpuCFZbxSqSZ+mEk7TvRLrf47ADzYfyBYXSDJy356bwdXtJu7XLoh8nmYehx7ceoPSQwy9X99fZ2Q69q+X11cXF8OeKYBuTOkpY0tWClQKQSTE1FozhVNXDKhIamKysrJRzbnI8YvO7z0T5+YrBD9Bih5Tkvc0Z5Qw4LYnXUCbbRI27f9gnJLNMy1Rdzeb627+QZA2n54kPQ47gz6N3Z02gm19uz4xn1+JiY7sOwxxAdzrrMVPteRzf47LTpsnfoI1mQ2/ts0+vYDeHpi1j3R/7r/6C/TdtXrwhTRHs5TD6SliiyIDcads5jtl6SlshCWNEwS0VhNBV111CNpaamko1QprQSk6mXg4/OT8M0Zmsf8SHM8ElLrnQ/4hH4eYCdiwhxConTL5kaN2GwE8TsU/2C5SwvGU9NU9xPMbtD6wJCxMRuG6bTvH9auOSMJShTCOhjttFxl33T4y49sLWtgCPaojQ1Mx1WyqpKQW2lVLIDUQEriwqEgLIzttHGNCVwwTsjsJZkQTCZnc35KpRSHVea1lpzKjgIqroCqdbAFGssGmO/6Jjd9qlPl9YqFIYqkA0VVSmpYYpRLiomDTSVxRSlD0lO3ovkyTUGj/1flG8chGEcupidQpB9EeFu8unvZnMgXpynr7PaywEeMWTJLe18iunJoMPhcPgDAAD//wMANAV6ewIEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoyUW2/cNhCF/4rAZ1GmSIq6vMVeGzXcZJ3sJi36xstoTVgmFyTl1DD2vxfUruts0oe+CdTozMczZ/SKnHwCNKAP+/31M7gUUYmSXY4ooQKTDlOyJd1A6EDaqhesJkz8hUpk7+AFDajlWte6B8xAAOaGNFixUWNjoK9JbQhteNaUu4iGVyRtZeDZaqh8/AYhWu/QgP6wzvjvube0lZ78bKrgJ7h1MUmnM8zqenO3Xd/jm1Z8ur68PFZGiFmgsgYNSNW17EwjcF03BvNaU9y3jGEqemWgFbJm5viZdQmCk1MVzeM7g/HJQdIDrSirCKY9OpTIyCQztpIRti/7TLLYtMrn5XK8PA6v6BkCGmj5ZuhR7kJP9uIpqtlO5mLyu511uyvvRrubg0y5cYn2we8hJAuLQetN8c5Uk4pUVFBCUIk+Sv1gHRS3q2I95RtLZkwHXOFOix7zhgmsSEcw5Q0RSveNgQx5B8HB9IPqR6uDj35Mxcn34qzPlXfJutnPsbh1CXYn0AHdyClCLphDAJeKe5keit9kfMikpjWNpgCmZqolaoSmM13T6dYI0YlR80YTVjeac81GZXqpVM805XRUHFqBSrSF8GSdnH73u1328t9+5y9WMMp5Shs/hyUZ0TyiEl1aJ8PLseJrBHOq+nQcxpvSEqhpWi5UnOZ5NVlw6cxfNCBRc8N7koOkJObt2GMJTGLViJ53ihkBOdb3wZtZp59HxghFJfoyu7xKxW0e1nfr8N+C/4T6AxvkXBVLlDWlou17jrUWNeYjUbiDusGCj20vteAUGCrRyutHCEWel7TuTGy9KT4E/WAT6DSHfM0/l97rTXE/yTT68HS2dus57edUfAFjA+gEmWIb5ix1Yyf4Bfbt3icPeXfMTfRvtd8gKB9tyr+Iz7OFbPDmuK6LHf9zW88035n+MxF5I+bk0eFwOPwDAAD//wMA4rnczdgEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUyW7jOBD9FYFnUaFEagXmkHYSjJFJ24jdM4O5cSnGRGTSoKikgyD/3qAst51GH+ZGFGt59epVvSPL94A6dH043L6ADQNKUTCTqSBFhUmDC7IlTUeKjrRZTeuqKcl/KEXmHt5Qh2omZS5bwBQqwEyREguqJVYK2pzkihQlizn504C6d8RNpuDFSMjc8Df4wTiLOvSPscq9xtrcZLJ3o8q862Fph8CtjGBubjf329Ua39XV19svX46eAwwxQWYU6pDIc96ossJ5XirMclngtqYUF1UrFNQVz6k6hhkbwFveZ4N6PmNQLlgIsiuygmYEFy36SJHigUfYgg+wfTtEJBNNN9GeTubp2b2jF/CoK9ITocd0V7I3V/tBjKZXV4H7Jwja8z28Ov8ML7xHKTp4dwAfDEz8rDbJGVJOMpIVVUEIStEDlztjIVneJKs+NsypUg0wgRtZtZiVtMKCNAQXrCSVkG2pIGK8B2+hv8j6YKR3g9MhmWlPPtVZOBuMHd04JEsb4MnzcIy74/0AKEXrUfRm2D0CV29b9zjGPzv2sZfF6D3YkKx52CV/8mEXm1C1KmUBoHIqaiI0lI1qykbWqqqaSktWSkLzUjImqRaq5UK0VBas0IJBXaEUrcZwGMOJ/+8XILbe7Pegzgi2E8frngft/H6pwAajTRzNyWMSVd9PXSVzzkVvwIZPJKMOVTlTrCVRTIJjVusWc6Aci7JqWSOoqiBKe+GsNk/jT56I1opRXTZNJVqREyh03TBSa0GU1FS2qhRQFzWpG2haSYgoWd6IVhIFecmiSNfeqVGGX6VASYFS9DjauKHJMrb9aiz+XrEzI2vvgpOujwMzPWzehgD7301t4fYHN5gQ+9fzaL8N8OBs/J1vwMzZHHztwoUzxEVIpt2ThNCa1hoXguWYcU1wW0mGCa1bqrnQTOQoRTdOPoNPosK4sdNQTqJabZJrL3cmgAyjj6X/nbo6zvPutDNnQrKvt9uF83B9OKSz9Y+XNosKXm2SkwA+3ZZvQ/TeuSF2Efx4oaMN9PoE60JNR+Elj6CMBxmmr+0xcObodwq79sFoLsMQ1+AvJydhzEqbXX6tdzGAn9HH4hdDmEUxZ2LNlGe6gJMU/tcB/Pj4+PgBAAD//wMAI6jx6vUFAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTS2/VMBCF/0rkdZz6HSc7aIuoKmhFK0Ds/Jj0Wk3tK8cpoOr+d+Tciwrs2EWjmfF3zpm8oGieAI3ozX5/+QyxLKhFJWwlRpjCRGNG7okeCRup6jSTgvb0G2pRuIafaES9cI66ATAHBVh4IrHlk8Pew0AJ9YRJUXeahwWNL8iEzsNzcNCl5TPkJaSIRvQlRJ++17dN6NycVt/lNMNVXIqJrsJcXN5d39/c4ne9+nj59u2xc4GlLuiCRyOylBrtpcKUSo8FdQwPPeeYqcF66JWh3B/HQiyQo5m7xT++MvhUIhQ3so7xjmA2oEOLvCmmYluzwP3PfSXZbLqo9XYrb5/jC3qGjEbW/jb0uO7MzeHMpacnE/3ZFGJYdqhF+5z2kEuAzZJN5TybElJsTo+czwFiQS26uWtuZ1OmlJ/+8unuKP3qP5R/MG4XIjRXF2hEigovBlLbrcGinwZsgBtspRqEttwrqLGdp1hCXNO6NFexwEPeKNGI3pl5gSPfm+x2oYAra67oX1UdhB+hnCdfCxS16DYnv7rSvNpNSUc6TljdsZb9WppP4EMGV6Bqus/raf0/I0wxQirZmjPE0tyasmvem2VXG3zvpWMAnnLbEzuB1F5L7XqvlFaTE9IRTqUTwvHJ+sFYO3DHBJusgF5V7ppusx0UcZJPWmssnOJYSGrxIBjD2knviVVeTZX+1dbmZq5zhnuvQVisnRqwkFxhSzTBTEiirBukh3o7vy05JS40atE15AjzH5I/BJfTkqbSnKJv/jLh0xrrz9psZ/A9RPxjM/8iuUfITQ3PhFjv8pTX4XA4/AIAAP//AwAiBvh48wMAAA==
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTyW7bMBD9FWHOokJSFLXckthBjSB1kBht0RuXcUxEoQyKyoLA/15QdpCmt14IYsB585bhO3j1hNDB+X6/fEYfR8ghurnEKZeENoTTDW06yjsmi4aXrRD8N+TgrvENOqiFMcy0SEqUSISlFdHl1hBrsWWUWcorkTDVwwjdOyhXWHx2Both/IFhdIOHDn46b4eXNFu5wvTDZIsw9LjyY1TeJDKL5f31Zn1Lrmr5fXlxcXw54pgACmehA82YamwlCWOVJYIZTtq6LAmXrbZYS8VKe2xzPmLwqi9G+/jJwQ7RYzQdL3hZUMJbOORgVVSJtlYjbt72icls0yLV87k8X7t3eMYAHc8/DD3CnZnenT2NenK9PTueUY2PSek+DHsM0eHsyyy171V0g89Oky57hz5CDuv77LZXcTuEpy9m3R/1r/5D/iZNX76imSLay2HyEToQHHK4UWbnPGarBXQgmbCipQlIKyLqbUsUloroSrai0aWVmFK9HHx0fhqmMVv5iA9h5g8dXKl+xCPz82B2LqKJU0iifsnUeBsGO5mYfdrPaEGLkiYm6ynup5jdoXUBTcQkbxOmE94/LVxyShOVKQT0MbtVcZd9U+MuPbC1rQxHtKzUNdVbrBrbVI2prZSN3BpRGVqyyghhyq22rdK6LQ0XfKsF1hJywJR2Ni+YsZLVumakaSpORKORtLxGoqjirNZMtiLtxKeP2bpPfaq0tkGhSWNkS0RVSqJpQwkXFZXatJXF1PdhySl80UAO1xg89n9JvnEmDOOwjdlpC7IvJtxNPn3ebN6IF+fJ6+z2YjCPGLKUlnI+7ekpoMPhcPgDAAD//wMA36GF6gMEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUy2rcMBT9FaP1yNHLsuxdmgcNIU1ohrZ0p8d1IuJIgySnDWH+vcgzpc2qdCPE9b1H555z8BsK+hnQiE53u4sXCCWjDSp+LTHCJCYKM7IlaiRspLJVlBCi1He0Qf4aXtGIemEttQNgDhKwcKTDhk8WOwcDJdQR1omKqR8yGt+Q9q2DF2+hjfkLpOxjQCP66oOLP+rb2rd2jotrU5zhKuSig61kzi/ur7e3d/iyl58uPnw4dGbIFaD1Do3IUKqV6ySmtHNYUMvw0HOOmRyMg15qyt1hzIcCKei5ze7pDwcXS4BiR9Yy3hLMBrTfIKeLrrSNzrB93VUmq0zntb5Zy+t1fEMvkNDINr8FPcCd2NmfPGez+NmdHM6i81Nez0nbEtMr2qBdijtIxcOq0WnO8Gzm163OT5eHnnrNFz/BLgXcWVxCQSPiEm3QqtE86+JjaI4Uz2YPoawfS/Ihe/svqIp0e9/czbpMMT2/s+T+oPLVf4h8o+2jD9BcnVdsKpwYSG03Got+GrAGrrHp5CCU4U5CTchZDMWHJS65uQoFHtK6EhrRpZ4zHPidJvvoC9iypLrnN1kH71J0iy3NHyspaUnLCatDS9ktpfkMziewBeoS27Qc8b68H2GSEVKpLClBKM2dLo/NR50fa4PrXWcZgKPc9MRM0CmnOmV7J6WSkxWdJZx2VgjLJ+MGbczALRNsMgL6KjDU5DRrWEnHeqGowsYMBAtKCB5Iz3FvFBAhmenNex2b27nOae6cAmGwsnLAouMSG6IIZqIj0tihc1Bz+VuSYx6EQht0DSnA/NfKN96mmONUmqPXzTsRPi+h/gia1fcfPuCfq9rn0T5Baqpb2oea+aNB+/1+/wsAAP//AwA/mN8hTwQAAA==
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUzW7dNhCFX0XgWpT5L1K7JHZQ10htxEZTdEdyRrYQXeqCopwGxn33gvIN3HSXHTHgGZ75eMgXkvwByUDeHY9Xz5jKSlpSpr0kmDCUWSrYA7MDE4Owneq16nn/N2nJdIPfyUB6FSOPDqlEg1QB0zTIMVIAdJxxYEKr2tM/rmR4IX7qAJ+niN2y/ol5nZZEBvJlSrB8q2f7qYvzskGXlxmv01p8itXM5dX9zcPtHf3Ymz+u3r9/3bniWht0E5CBGBCSGeQUmBFUKbTUgkFqDSgWOdcC3KtsSgVz8nO3wtc3D7CUhCUOohOyY1Q4cmoJ+OKr7eBXfPh+rE52TJe13u7lfTm8kGfMZBDtD6Cv7S7iPF2U5TjjM85Hn1fMF3E5HHwC0pJjXo6Yy4Q7mn3aefZlWlJzPuzDPGEqpCW3983d7Mu45MNPvO5fEVz/AoFnzIEMRHoFQSFy1C4i98zz4EZtAhjQmhmwUTCFYuSgGQ+9l14xwdFIo9FCIC15nJfg59/XnZ8TLFhgvTACFTqhjHGRobQ46h4ZeGOCsdGBtL1QFqz0znvuAvbRmVhD8snHpylhc31Zp+EKlGNAFQ+eqn501KP0NGjjlA0SDFbNhyWVKW3LtjbXqeBj3vmRgXz084qv5N7l+DQVjGXLFepfpgrv8gJbLM1bAjjrWCeZqKKtHLfSfEaYMsaCFe9D3s79/icRRjBWrWw5YyrNnS9PzW9+faoboAcdBSJwGXoWRtQWrLaxB2OsGaPSkUmuo1JRjgGcD8HJKJQYg8LekJZgDVyzZ1yMVpjYR9rzqKnSY6ROiJ5G5ZkPzslRVSNvHJvbueq8BLCoArXROKq0NDQwy6hQmpkQnQascf6B5Bw+ZUlLbjAnnP8z8qcp5mVdxtKcU9j8BOHzlur/0eyJ/DYl+s9O+3KJXzE39bb8lOpTOV/QqSUH9OuW8bB/P8MLuavvpHnYfyHOVWeYasl98blsx3PZik5o6U6n0+lfAAAA//8DAFrkfDnDBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUXU/dOBCG/0rk6zh1HNtxcteeA1qECghQt9o7xzMuVoN95Dh0EeK/r5xzaJfe9S4Zzcc7z7zJCwnmEclIPh4OZ08Y8kJqkv0W4owryjTl7J7pkfGRD83Ateql+IfUxF/iMxlJL6xt7YC0Q4VUAJN06pylADi0rAXGpSg9zbeFjC/E+AbwyVts4vIF0+JjICP52weIP8ps4xs7xxWaFGe8CEs2wRYx+7O7y/vrG3req6uzT5+OmQsupUHjgYxEAe+YwpYCU5wKgZpqUEi1AsFs20oOw7HMh4wpmLlZ4PsvDRBzwGxH3vCuYZQP5LUmYLIpsiez4P3zoSjZMO1LvN7C2+P4Qp4wkZHXb0CP7T7Y2X+w8fHRBEi4xHnNPoZ3kScEUpNDigdM2eNGaVt8nk1Jrk5zd7PHkElNru+qm9lkF9PjO3SnplfH+RLA9b3g6CahcEIG2DPX6W5iptWub1sjmXOqFziBYL2VAx80ain7fuhFQXV3xHvxB3RPGm6PiyUyks/eprhEl5t9zFeYm93sm90x7dzYHNPz2+vtT0DNjckPu9961eSzsQ8+YHWxL4paAWJgQEU7GSp6N1CDnaGTVIPQUwcKi/F2MWQf1rgu1UXI+C1tUMlIzs284BHnx2QffEab11TQfVWl8CZFWG2ufjmkZQ1rOsZL0ZoPa65uEXxCm7Eguk/rqd9vJVxxxoqUNSUMuSrLVX+Z5aEkQA/SckRou6lnk0OpQUtte1BKK2eFtKxrpRXCdm6CwUzT0FkuuJsE9orUBIshq+0bMI5jy6WjDpyjwihOtW0N5S1zApwUvHXvOFbX81bXAWgUE9VWDVTITtGJaUa5kExNdpCAxe5vSE6OFJrU5BJTwPl/K/88eHWyZvUOwu0ayv+l2lz1wwf670Z7H+13TFW5lvFhc87xQK+vr6//AQAA//8DAME6mqamBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUUW/cKBSF/4rFs3EwYAx+a5tUm426GTWj7WrfLnCdoHrwCOPpVtH89xWeqbLdt76hK869h48DryTCAclA3h2PdyeMeSE1yWErccYVZZpytmd6YHwQrFFScsP136Qm4QG/k4H00rnWGaQCFVLpWUetGB31Hk3LWs94J0tPeF7I8EogNB5PwWEzL39iWsIcyUC+hOjnb2U2hMZN8+qbNE94H5cM0RUzt3dPD/vHHf3Yqz/u3r+/7FxwKQ2a4MlARj0q3VpO21GOVI5aU809o71BPhrhWc/1RRZixhRhahb/9c2Dn3PE7AbecNEwyg0518RDhmLbwoL778fiZMN0W+r1Vt6Wwys5YSIDr38AvbS7cVO4yfNxwhNOR0gLphs3Hw4QPanJMc1HTDnghmY77TRBDnOsrsM+TAFjJjV5fKp2E+RxToefeD1dENz/AoETJksG0ukORNsjSK/R+FF1yE0ndMtQShA99wzGUSrhvGJGjQjcO+lc33LujVCkJs/TbGH6fdn4Gc6sLjMUR4mGS6WMYyg0jl2PzINSVmlnvNA9l9prAQagNRZ7Z5QrIfkE7iVErO5vyUBUK700zFPZWqCyHw0FFEBtp4zUVniFRfNhjjnEdV6X6j5mfE4bPzKQjzAteCH3LrmXkNHlNRWof6ki3KXZry5XbwloWcMawXgRrfm45uoz+pDQZSx492m99vufhCvOWLGypoQxVzvIL9VvsLyUDb73neOIvhW2Z3bETnvdadd7pbQanewcE23npHRitN6AtUY4LvloJfaFMpbAVVvGpWHGcqepEYZRyTRQDb2hXScQmDFC+uL+jWP1OBUdCO81Sku1U4bKTihqmWaUy44p60znscT5B5Jr+GRJywOmiNN/jvwpuDQv85irawqrnyB8XmP5P6otkd9CpP9stG9n9xVTVW4LQixP5XpB55ocEJY14WH7foZXsivvpNpvv5BRjVBC1+QpQ8rr8VpuedvwlrXn8/n8LwAAAP//AwAW3FooxAQAAA==
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoyTT2/bOBDFv4rAs6hQFEVRujVxgg3S1kHt3S72xj8jh4hCGiSVNgj83QvKTlu3PezNoEdvfvPmzSty8gnQgN7t99fP4FJEJUp2eaKEckwEpmRLxEDo0JBKNIxxIf5DJbJ38IIG1DGta90DboADZoa0WDWjxsZAX5PaENqyrCl3EQ2vSNrKwLPVUPn4D4RovUMD+myd8V9yb2krPfnZVMFPcOtikk5nmNX15m67vsc3Hf94fXl5rIwQs0BlDRrQKEYuakVxPbIRs1EILKghuOuBjn1jSEfF8TPrEgQnpyqaxx8MxicHSQ+0ok1FMO3RoURGJpmxlYywfdlnksWmVX4vl+fl5/CKniGggZZvhh7lLvRkL56imu1kLia/21m3u/JutLs5yJQbl2gf/B5CsrAYtN4UP5hqUpGKckoIKtEHqR+sg+J2VaynPLFsjBHAFBaa95i1DceKCIIpawlXum8NZMg7CA6mn1Q/WB189GMqTr4XZ32uvEvWzX6Oxa1LsDuBDuhGThFywRwCuFTcy/RQ/CXjQyY1nWk1BTB1ozqiRmiFEa3QneFc8FGzVpOmbjVjuhmV6aVSfaMpo6Ni0HFUoi2EJ+vk9N7vdtnL7/3O/1jBKOcpbfwclmRE84hKdGmdDC/Hir8jmFPVx+My3pSWQE3TMlBx2ufVZMGlM3/RgHjNDOuJwaxWErNu7LGERmLV8p4J1RgOOdb3wZtZp19X1hCKSvRpdvmUitu8rC/W4a+c/YL6ExvkXBVLlNu6q7VmFEtGCWaUSNwzSjEXtRTQEMlUjvLK60cIRd6XtO5MbL0p3gX9YBPoNIc85r9L7/WmuJ9kGn14Oju79Zz2cyo+gbEBdIJMsQ1zlrqxE/wG+zb3yUOWcTbHa1ym/Z/HeOVd9H+Q/+PGc+Ln5NHhcDh8AwAA//8DAKH1mMy4BAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUS2/dNhCF/4rAtSjz/dAuiR3UMFIbsZEW3VHkMCaiS11QlNPAuP+9oO4NUnfXnTDgGZ355pCvKLsDoBG9Ox5vXiDXFfWopr3ECFOYGMzIEzEjYSMngyaMc0n+Qj1Kd/ADjUgL76m3gDkowCIQiScePQ4BLCU0ECZF6+m+rmh8RS4NAV6Sh2FZv0BZ05LRiP5IOSzf279dGvy8bGEoywy3ea0u+2bm+ubx7un+AX/U6veb9+/PJ1dYW4MhBTSiaKIydGKYRhGxiMZgwwLB2gKLlgeimTnLUq5QspuHNXz75SEsNUP1IxsYHwhmFp16FFx1zfbkVnj6cWxOdkzXrd7v5f1zfEUvUNDI+p9Az+2u/Jyu1m2a4QXmoysrlCu/HA4uB9SjY1mOUGqCHc0LlGlZU21UvdfOGEqDcVpIHQyXjOvACQRDtAw2WKeEisxLGcNkrBZRBEZFUCDEpFGPdnjz7Gpacnfx/mFOkCvq0f1j9zC7GpdyeIP/8Uz09n8AbbbRiKSRjlMNTgQDNkQlgVnJDSUghOOaBeJiFIr7oIhVERwLXnivKWPBcoV69Mn555Shu71GI1JUBGFJwIJODgsdLXbAHZ6kssJMvA2KevRhyTXlbdnW7jZX+Fr2edGIPrp5hfOk74p/ThV83UqD8KdqwoeyhM3X7lcAKBnIwAlroq0et9p9hpAK+AoNx1PZLv3+I2GKEdKsbKVArt2Dq8/db259bgeCDtIzgED5pMkUQZpgpPE6KGVU9EJ6wqn0Qngep2DdNFnumWBxEqAbFWh5684Rt0IyFSV2QAUWlig8Ea8x8UIpb8ATbt9w7O7npnM8BANiwsYri4XkTWcIZkISNXkrA7Q0/0RyCYto272DkmH+18ifki/LusTaXVLTvYHwecvt+ej2BH1PGf+9075e/DcoXduWS7ndlMuCTj06gFu3Aof99Rlf0UO7Jt3T/ghZNXDFTY8eqyt1O17KlNGBUUJPp9PpHwAAAP//AwACiQ5HwwQAAA==
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTS0/jMBD+K5HPdfArjpMb0KKtEEsF1e5qb35MqEVwKsfhIdT/vnJaxLK3vVjRxDPzvfyOgn4C1KLz/X71DCGNaIGSn0uMMImJwoxsiWoJazkt65qwmtHfaIH8NbyhFtXCWmobwBwkYOFIhQ3vLHYOGkqoI6wSeaZ+GFH7jrQvHTx7C+Uw/oA4+iGgFv30wQ0vebf2pe2HyZVx6GEdxqSDzWCWq/vr7e0GX9Xy++ri4nhzhDEPKL1DLepUJxU1DNNOdFh0SmHFHMF1A6xruCM1U8c2HxLEoPtydI+fGNyQAiTbspLxkmDWoMMCOZ10hm30CNu3fUYyy7TM9cVcnj/bd/QMEbVs8SHocdyZ7f3Z02gm37uz45n0+JiZ7uOwh5g8zLrMVPteJz+E4rTpsvcQElqg2/ti0+vUDfHpi1j3R/7r/6C/zdtXr2CnBO5ymEJCLco/brTd+QDFeolaJKlwoiEOC2o0FnXXYA1cY1PJRijDnYRs6uUQkg/TMI3FOiR4iDN81KIr3Y9wBH4e7c4nsGmKmdMvmRs3cXCTTcWn+pSUpOSE5aYp7adU3IHzEWyCzG4bp9O8f1qYZIRkKFOMEFKx0WlXfNPjLl9wtassA3CUm5qYDirlVKVs7aRUsrOisoTTygpheWdco41puGWCdUZALdECQTa7mPPlKmC04h2GmtdYOMqx0VWHK9m4ChSlBugXHYvbPvdp7pwCYbCyssGi4hIboghmoiLS2KZykKP0IcnJe5E9uYYYoP+L8o23cRiHLhWnEBRfRLibQn67xRyIFx/w66z2crCPEIvslvYhx/Rk0OFwOPwBAAD//wMAz5D+vAIEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRV227cNhD9FYGvlRTeJFEC8uCs16kR+ALvoin6xsvQy1omFyTl1DX23wtp7WadoA990WXImTlz5gz5grx8BDSgs/1+/QQ+J1Si7BYTxbStsKgo3mIxYDowUncC44azP1CJ3Bd4RgPquNZE91AxaKHiBjeVYlZXxkBPMDGYNnyOKe8TGl6QdLWBJ6ehDuk3iMkFjwb01XkTvs25pav1GCZTxzDCpU9Zej2DOV9vvmxvbquLrr1ef/p03JkgzQFqZ9CArLCtIIpWxHJbcStEJajBVdcDtT0zuKPi6OZ8hujlWCfz8B2DCdlD1gOtKatxRXt0KJGRWc6wlUywfd7PSBaazmd7uZiXz+EFPUFEAy3fCD2G+6BH9+ExqcmN5sPyRCXax7CHmB0sjCxFjqPMLvjiNcdqdOAzKtGn2WUzaQ0poQFdyDHBm3nt752Hiygf4VuID9fHvPX1eotKdBvDn6Dzrcw7NCBmtTBWaSJ5r6wC07e4kdBLyxUX1hhGtNWUWNx2naLcMmGUkm3bNUYpSlGJbjbF7SizDfHxXcM2xx5c/o8WbM5Way/VCObHkrYy3kNGA1KEWC26vueGNr1hILUATlplsBSCAQfDur63RDBGmSBWYCV62QisGuCGoRJdpk2W3sgxeFj/BXqaCUYD2sZpzncl9c55KC7P0YBawg3vsak4UbLine0rCUxWqml7LhQzLcwivpBunCKsZIb7EGf1X0+fYe7TKvjs/BSmVFz6DPdxaedJeTeb4izqncug8xTnRv3eziFvYzCTzsV3HRJc45rhhfMp76dc3IFxEXRe+HqFf7P50YW2FOMZyhQj+FzMrS9+lWnuPzGdaTQFMISpDisLjTCiEbozbStaq3mjMSON5lwzq0wvleqZppxaxaFrUYlgln2xTBontLWNtZViXVNxqk0lW6MrQbGGtmecA33HcHEzzn6SGSOAq0rotq94w9pKYYEryhvcKt03BuR7cZ+UKOq2JqRmDOP+hLfXgeGzrtYxhrgK03yKDad/l96G4qVYhce9GyEWH4uyuEpLns/gIcrx1LR+kuN0HMdT65uE3nlHud8thq1MD8vHxjzcQQrjE8S0GK4hb8xxbVFL8bGgZbG4r3agX1dkdk+wDWHUO+mOOVbBwJmX43Nyx0h38u9wBP91b5f3WdpfLxHL4ibvlsqKAyrRF4gexhOFXDkdQwo2F6+TW7zTzAnl5y7tR/n8E/O/sEY1PVOg3vYv6H8e5LvJzxdIsZwI35yv/lqEfh70A8RiHhTp/HxW/utxNY3ZbXcRpAFzFQz8GPRQokeQaYrwuFxRy6npIS4wzqfjsF36KzeOLoEO3iQ09O0Mm/NXtP+1jRAiaoKFOBwOh38AAAD//wMAez5l7Q8HAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTTW/cIBCG/4rFeXEwYIx9S/OhRlGaqFm1VW98DA2KAyvASaNo/3uFd6s2vfWC0GhmeOd5hzcU1BOgCZ3udhfPEEpGG1T8GqKECkwkpmRL5EToxLp2EF0vB/YdbZC/hlc0oYEb05kRMAMBmFvSY82cwdbC2JHOEtrz2lP9yGh6Q8q3Fp69gTbmL5CyjwFN6KsPNr7Ut5VvzRwX26Y4w1XIRQVTxZxf3F9vb+/w5SA+XXz4cMjMkGuD1ls0ISedkJ2muHPcYe6kxJJagocRqBuZJQOVhzIfCqSg5jbbxz8abCwBiploS1lLMB3RfoOsKqrK1irD9nVXlayYzmt8s4bX6/SGniGhiW5+Az20OzGzP3nKevGzPTmcReXHvJ5OmRLTK9qgXYo7SMXDyug0Z3jS8+tW5cfLQ0695oufYJYC9iwuoaAJ1XFWRPOsio+hOSo8mz2Egjbo9r65m1VxMT29g3x/4Hb1H9hulHnwAZqrczQh0XHLR2Ix77TCfHAjVsAU1r0YudTMCqien8VQfFjikpurUOBHWlWiCV2qOcNB32kyD76AKUuq0r+JWniXol1Maf6Y05GWtIzQWrSU3VKaz2B9AlOgDrFNy7HfPyVUUEKqlCUlCKW5U+Wh+ajyQ02wg+0NBbAd0wPRDnppZS/NYIWQwhneG8K63nBumNN2VFqPzFBOneYwCLRBUHehWddPO6p6xwbcEWswF85g1VGCpVOiBys1Z8M7js3tXOsUs1YC11gaMWLeM4E1kQRT3hOhzdhbqJv2G8nRYl49uYYUYP5r5BtvUszRlebodfMOwucl1K/drL6/+IB/rrTPo3mE1FS3lA91i48G7ff7/S8AAAD//wMAGYtnLCEEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTS2/cIBD+KxbnxQGMMfatTVI1itpESdRWvWFmyKI4sMI4D0X73yu8W7XprTc0mvnmewxvJJhHJAP5sNudP2HIM9mQ7NeSYEJRpqlgd0wPTAwNrzvNOeftT7Ih/hJfyUA6aS23PdIGFVIJrKVj4ywFwJ4zDky0smCa+5kMb8T4GvDJW6zj/A3T7GMgA/nuA8Tnstv42k5xgTrFCS/CnE2whczZ+e3l3dU1/dSpr+cfPx46Z5wLQO2BDMRppzQfBeVOOiqd1lQLYLTrUbi+AdYJfRjzIWMKZqpnePjDAWIOmO0gatHUjIqe7DcETDaF9mhmvHvdFSarTWelvlnL63N4I0+YyCA2vw09wJ3YyZ/Y+PhoApw4H/y8JRuyS3GHKXtcLVlVTpPJPobquOR08hgy2ZCr2+p6MtnF9PjOp9uD9Iv/UP7F2K0PWF2ckYEoLkH2DKjko6Gycz012Bg6tqqXemxAYYntNIbswxKXuboIGe/TypIM5JOZZjzw+5Ds1me0eUmF+g9VBvHF59MIpcDJhlynCIvN1R+7OatZ3TBRMJa8W3J1g+AT2oxF011ajvD/jAglGCvMlpQw5Ora5G312czb0gAdtFYgAm/Gjo0OWw261bYDpbRyVraWNby1UtrGjdCbcewbK6Rwo8ROFd4l3Wo9KN5oyXXHqbXgqIQeae9YSyU40K2UQoF7Z2t1NZU50wBolCPVVvVUto2iI9OMCtkyNdq+BSy389uSY+KyRHSJKeD0l+Qv3qY4R5erY/TVOxNullA+a7WewbMP9GU1/yzaB0xVCc/4UO7ymNd+v9//AgAA//8DAFu9oLHzAwAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyhT/KZuSeyghpHaiBdp0Rs/hjERLbWgKKeBsf+9oLyBm956I4ac4TsPX84LyvYIaELvTqebZ8h1RT2qaQ9RQiUmGlNyIHoidGJ0oJwpQfhfqEfpDn6gCSnu/egNYAYSMA9EYMeixyGAGckYCBW81bRfVzS9IJuGAM/Jw7CsX6CsacloQn+kHJbv7W6bBj8vWxjKMsNtXqvNvom5vnm8O9w/4I9K/n7z/v3ryRXWVmBIAU0oaiV15Ap7KTTmnglseTRYK0lFZFYANa9pKVco2c7DGr69aQhLzVD9RAfKBoKpQeceBVttk+3sCocfp6Zkx3Td4v0e3pfTC3qGgiba/wT6Wu7Kz+lq3dwMzzCfbFmhXPnleLQ5oB6dynKCUhPsaJ6huGVNtVH1XlmtxzFoq7hQQTNBmQqMQNBEiWCCsZLLSL0QMThtFI880JEHCZw7hXq0w5tnW9OSu4v2D3OCXFGP7h+7h9nWuJTjL/gfX4ne/g+gTTaakNDCslGB5UGDCVG2bcH0SIBzyxQNxMbIJfNBEiMjWBo8916NlAbDJOrRJ+ufUobu9hpNSI48cEMC5qOzmKtosAVmsRPScO1YaxT16MOSa8rbsq3dba7wtez9ogl9tPMKr52+K/4pVfB1Kw3Cn7IlPpQlbL52bwYYyUAGRmhL2uppq91nCKmAr9BwHMp2qfefFCopIU3KVgrk2j3Y+tT9ZtendiCoIDwFCCNzirgIQgcttFdBSi2j58ITNgrPuWfRBWOdM8xTTqPjoBoVaH7rdosHDcyNRGNposXceIeNNA7T6AUnI2hhm63eOHb3c8uzLAQN3GHtpcFcMIkd0QRTLoh03ogAzc0/kVzMwjXq0R2UDPO/Wv6UfFnWJdbu4pruFwift9zGR7c76HvK+O+d9vXiv0Hp2mvZlNtPuTzQuUdHsOtW4LhPn+kFPbRv0h32ISTUIAjTPXqsttTtdAlLMQhB2Pl8Pv8DAAD//wMAUcbLucIEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUS2/cNhSF/4rAtShTfFO7JHZQ10g9iAdN0R0fVzYRDTWgqEkDY/57Qc0EqbvLTrjiuTz8eHhfUbIHQAN6dzzenSCVBbWoxK1ECZWYaEzJnuiB0IHRrpdaCS3/Ri2KD/AdDUhx73tvADOQgHkgAjs2ehwCmJ70gVDBa0/7vKDhFdnYBThFD928/Al5iXNCA/oSU5i/1b1t7Pw0r6HL8wT3aSk2+Wrm9u7pYf+4wx+V/OPu/fvLygWW2qCLAQ1o1ErqkSvspdCYeyaw5aPBWkkqRmYFUHORxVQgJzt1S/j600OYS4LiB9pR1hFMDTq3KNhiq21nF9h/P1YnG6bbWm+38vY5vKITZDTQ9gfQS7sbP8WbMh8nOMF0tHmBfOPnw8GmgFp0zPMRcomwodlOO022xDk1180+TBFSQS16fGp2ky3jnA9veD1dENz/AoETZIcGJLSwrFdgedBgwijrb8F0T4BzyxQNxI4jl8wHSYwcwdLgufeqpzQYJlGLnqfZ2en3ZeNnKHE6EEUlBQ6GcimNJ8A0jEIBCVZKJ7U3gWlFuQ6aWWNtbxwob6SvIflk/UtM0NzfogHJngduSMC8dxZzNRpsgVnshDRcOxYkVM2HOZWY1nldmvtU4Dlv/NCAPtppgQu5d9m/xAK+rLlC/UtW4S7PYfWl+ZmAnnSkY4RW0VqOa2k+Q4gZfIGKd5/Xa7//SaikhFQra86QSrOz5aX5zS4vdUFQQXgKEHrmFHEjCB200F4FKbUcPReesF54zj0bXTDWOcM85XR0HFSlDDVwzZZxanrDKCHYjJxibpzERjuFGVHeh970AtQbjs3jVHWWhaCBO6y9NJgLJrEjmmDKBZHOGxGgxvkHkmv4uEYteoCcYPrPkT9Fn+dlHktzTWHzBsLnNdX50WyJ/BYT/mejfTv7r5Cbels2pvpUrhd0btEB7LJmOGzjZ3hFu/pOmv02hYTqBGG6RU/F5rIer2UpOiEIO5/P538BAAD//wMARF4PZcMEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTy07cMBT9lcjrODi24zjZwQyoCFEQjNqqOz9uGItgjxyHh9D8e+XMIEp33VlXPsfncf2OvHoC1KPT3e78GXyaUImSW0aUUIGJxJRsiOwJ7Rmrmo4TIdrfqETuCt5Qj1puTG06wAwEYG5JgzUbDLYWuprUltCGZ071MKH+HSlXWXh2Bqow/YA4ueBRj346b8NLflu5yoxhtlUMI1z6KSlvspj1+f3V5uYWX7Ti+/nZ2eHmBFMmqJxFPRpkK+TAW2xEIzE3rMGKDx2WraDNwFQDtDvAnE8QvRqryT5+arAheUimpxVlFcG0Q/sSWZVUlq3VBJu3XVayxLTO83IZL8f+HT1DRD0tPwI90J2Y0Z2Y8PSkvD0ZnHfTFpVoF8MOYnKwRLK4HEeVXPDF8ZHV6MAnVKKb++J2VGkI8elLTvcH65f/4fxama3zUFyuUY9EzS3viMW81grzduiwAqawbkTHpWZWQK5tFXxyfg7zVFz6BA9xUYl6dKHGCQ76TqPZugQmzTFL/yUyEF5dWgWbBwSV6DYGO5tUfMZdk4pUjNDMMafdnIo7sC6CSZA9beJ8pP8HQgUlmXE1xwg+FbcqbYtvatrmC7a1jaEAtma6JXqARlrZSNNaIaQYDG8MYXVjODds0LZTWnfMUE4HzaEVWXdut1gWCqxqKa8VZtBpzBlrsWZCYU25YhIoJZB34DPW4mbMOMWslcA1lkZ0mDdMYE0kwZQ3RGjTNXbBfURybJxLVKIriB7GvyxfOxPDFIZUHKsvvoRwN/v8WYtlDV6cx69L+OtgHiEWuTzlfN7LY1/7/X7/BwAA//8DAEtP7wnzAwAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTW/cIBD9KxZn42DAGPvWZlM1itpEzaqtesPMOIvihRXGaato/3uFd6M0vfWGRrzH+xieiTd7JD15dzhcPaFPMylJcuuIM64o05SzLdM9470QVdsIxYT+QUribvA36Ukrra1th1SgQiqBNXQQo6UA2NWsBsYbmTnNw0z6Z2JcBfjkLFZh/opxdsGTnnxzHsLP/LZxlZ3CAlUME177ORlvs5jN1f3N9vaOfmjV56v37083Z5wzQeWA9EQBF0xhTYEpTqVETTUopFqBZLauGw7dCeZ8wujNVM3w+KoBQvKYbM8rLipGeUeOJQGTTJY9mBm3vw9ZyRrTJs/Ldbwe+2fyhJH0vHwJ9ER3YSd3YcN+bzxcjM67eUdKcojhgDE5XCNZXU6TSS744vzI5eTQJ1KS2/vibjJpDHH/Jqf7k/Xr/3D+ydid81hcbzKmliA7BlTWg6GyHTtqUBg6NKqTehCgMNd2GXxyfgnLXFz7hA9xVUl68sFMM570vYt25xLatMQs/bvKQPzl0mWAPKhJSe5igMWm4jXumlWsEoxnjiUdllR8QXARbcLsaRuXM/0/EK44Y1nZEiP6VNyZtCs+mnmXL0ALjeWIUIuhZcOIjQbdaNuCUlqNVjaWibqxUloxDtCZYeiE5ZKPg8RWZd253WJdKItqHBvZ0tpoSSWIjupOaQq1sFZzrqXJQl5jLW6njDMCQKMcqLaqo7IRig5MM8plw9RguwYw785LJOfGpSYlucHocfrL8idnY5jDmIpz9cWbEL4sPn/WYl2Dn87TX2v4m2AfMRa5PON83stzX8fj8fgHAAD//wMAJODDNvMDAAA=
+225
View File
@@ -17,6 +17,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
@@ -29,6 +30,26 @@ 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; }
public DbSet<DriverProfile> DriverProfiles { get; set; }
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
public DbSet<RideCancelRequest> RideCancelRequests { get; set; }
public DbSet<Scheme> Schemes { get; set; }
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
public DbSet<LoadingSheet> LoadingSheets { get; set; }
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
public DbSet<Payment> Payments { get; set; }
public DbSet<DeliveryTask> DeliveryTasks { get; set; }
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -57,6 +78,210 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
.IsUnique();
modelBuilder.Entity<PriceListItem>()
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
modelBuilder.Entity<Vehicle>()
.HasOne<Warehouse>()
.WithMany()
.HasForeignKey(nameof(Vehicle.DefaultWarehouseId))
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.SourceWarehouse)
.WithMany()
.HasForeignKey(r => r.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.DriverProfile)
.WithMany()
.HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
.WithMany()
.HasForeignKey(s => s.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.CreatedByUser)
.WithMany()
.HasForeignKey(s => s.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Order>()
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
.WithMany(s => s.Redemptions)
.HasForeignKey(r => r.FK_SchemeId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.FreeItem)
.WithMany()
.HasForeignKey(r => r.FK_FreeItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Scheme>()
.HasOne(s => s.TriggerItem)
.WithMany()
.HasForeignKey(s => s.FK_TriggerItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Scheme>()
.HasOne(s => s.FreeItem)
.WithMany()
.HasForeignKey(s => s.FK_FreeItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeBundleComponent>()
.HasOne(c => c.Scheme)
.WithMany(s => s.BundleComponents)
.HasForeignKey(c => c.FK_SchemeId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SchemeBundleComponent>()
.HasOne(c => c.Item)
.WithMany()
.HasForeignKey(c => c.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeBundleComponent>()
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
.IsUnique();
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.SourceWarehouse)
.WithMany()
.HasForeignKey(s => s.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrder>()
.HasOne(po => po.Supplier)
.WithMany()
.HasForeignKey(po => po.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasOne(i => i.PurchaseOrder)
.WithMany(po => po.Items)
.HasForeignKey(i => i.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.PurchaseOrder)
.WithMany()
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Warehouse)
.WithMany()
.HasForeignKey(gr => gr.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasOne(i => i.GoodsReceipt)
.WithMany(gr => gr.Items)
.HasForeignKey(i => i.FK_GoodsReceiptId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasOne(i => i.PurchaseOrderItem)
.WithMany()
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseReturn>()
.HasOne(pr => pr.PurchaseOrder)
.WithMany()
.HasForeignKey(pr => pr.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseReturnItem>()
.HasOne(i => i.PurchaseReturn)
.WithMany(pr => pr.Items)
.HasForeignKey(i => i.FK_PurchaseReturnId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseReturnItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride)
.WithMany()
.HasForeignKey(t => t.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Order)
.WithMany()
.HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
}
}
@@ -14,63 +14,6 @@ public static class RoleAndPermissionSeed
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 (string Code, string Description) [] PermissionDefs =
[
("regions.view", "View regions"),
("regions.create", "Create regions"),
("regions.update", "Update regions"),
("regions.delete", "Delete regions"),
("routes.view", "View routes"),
("routes.create", "Create routes"),
("routes.update", "Update routes"),
("routes.delete", "Delete routes"),
("vehicles.view", "View vehicles"),
("vehicles.create", "Create vehicles"),
("vehicles.update", "Update vehicles"),
("vehicles.delete", "Delete vehicles"),
("stores.view", "View stores"),
("stores.create", "Create stores"),
("stores.update", "Update stores"),
("stores.delete", "Delete stores"),
("warehouses.view", "View warehouses"),
("warehouses.create", "Create warehouses"),
("warehouses.update", "Update warehouses"),
("warehouses.delete", "Delete warehouses"),
("orders.view", "View orders"),
("orders.create", "Create orders"),
("orders.update", "Update orders"),
("orders.delete", "Delete orders"),
("users.view", "View users"),
("users.create", "Create users"),
("users.update", "Update users"),
("users.deactivate", "Deactivate users"),
("users.assign_role", "Assign a role to a user"),
("users.assign_scope", "Assign region/route/store scope to a user"),
("items.view", "View items, brands and UOMs"),
("items.create", "Create items, brands and UOMs"),
("items.update", "Update items, brands and UOMs"),
("items.delete", "Delete items, brands and UOMs"),
("categories.view", "View categories"),
("categories.create", "Create categories and subcategories"),
("categories.update", "Update categories and subcategories"),
("categories.delete", "Delete categories and subcategories"),
];
private static readonly Dictionary<string, Guid> PermissionIds = PermissionDefs
.Select((p, i) => (p.Code, Id: DeterministicPermissionId(i)))
.ToDictionary(x => x.Code, x => x.Id);
private static Guid DeterministicPermissionId(int index) =>
new($"20000000-0000-0000-0000-{index + 1:D12}");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Role>().HasData(
@@ -81,66 +24,5 @@ public static class RoleAndPermissionSeed
new Role { Id = RegionalManagerId, Name = "RegionalManager", Level = 50, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Permission>().HasData(
PermissionDefs.Select(p => new Permission
{
Id = PermissionIds[p.Code],
Code = p.Code,
Description = p.Description,
CreatedAt = SeedDate,
IsActive = true
})
);
var itemViewPerms = new[] { "items.view", "categories.view" };
var itemManagePerms = new[] { "items.view", "items.create", "items.update", "categories.view" };
var itemPerms = new[] { "items.view", "items.create", "items.update", "items.delete", "categories.view", "categories.create", "categories.update", "categories.delete" };
var salesRepPerms = new[] { "orders.view", "orders.create", "orders.update", "stores.view", "routes.view" }.Concat(itemViewPerms).ToArray();
var supervisorPerms = salesRepPerms.Concat(["orders.delete", "stores.create", "stores.update"]).ToArray();
var areaManagerPerms = supervisorPerms.Concat(["vehicles.view", "warehouses.view"]).Concat(itemManagePerms).Distinct().ToArray();
var areaSupervisorPerms = areaManagerPerms.Concat(["routes.create", "routes.update"]).Concat(itemPerms).Distinct().ToArray();
var regionalManagerPerms = new[]
{
"orders.view", "orders.create", "orders.update", "orders.delete",
"stores.view", "stores.create", "stores.update", "stores.delete",
"routes.view", "routes.create", "routes.update", "routes.delete",
"vehicles.view", "vehicles.create", "vehicles.update", "vehicles.delete",
"warehouses.view", "warehouses.create", "warehouses.update", "warehouses.delete",
"regions.view", "users.view",
"items.view", "items.create", "items.update", "items.delete",
"categories.view", "categories.create", "categories.update", "categories.delete"
};
var regionalAgencyPerms = PermissionDefs.Select(p => p.Code).ToArray();
var rolePermissionMap = new (Guid RoleId, string[] Codes)[]
{
(SalesRepId, salesRepPerms),
(SupervisorId, supervisorPerms),
(AreaManagerId, areaManagerPerms),
(AreaSupervisorId, areaSupervisorPerms),
(RegionalManagerId, regionalManagerPerms),
(RegionalAgencyId, regionalAgencyPerms),
};
modelBuilder.Entity<RolePermission>().HasData(
rolePermissionMap.SelectMany(rp => rp.Codes.Select(code => new RolePermission
{
Id = DeterministicRolePermissionId(rp.RoleId, PermissionIds[code]),
FK_RoleId = rp.RoleId,
FK_PermissionId = PermissionIds[code],
CreatedAt = SeedDate,
IsActive = true
}))
);
}
private static Guid DeterministicRolePermissionId(Guid roleId, Guid permissionId)
{
using var md5 = System.Security.Cryptography.MD5.Create();
var bytes = md5.ComputeHash(roleId.ToByteArray().Concat(permissionId.ToByteArray()).ToArray());
return new Guid(bytes);
}
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
[ApiController]
[Authorize]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
{
[HttpPost("rides/{rideId:guid}/tasks/generate")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
{
try
{
var result = await service.GenerateAsync(rideId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("rides/{rideId:guid}/tasks")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
return Ok(result);
}
[HttpGet("delivery-tasks/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("delivery-tasks/{id:guid}/quantities")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{
try
{
var result = await service.SetQuantitiesAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("delivery-tasks/{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("delivery-tasks/{id:guid}/fail")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{
try
{
var result = await service.FailAsync(id, request.Note, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,24 @@
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record DeliveryTaskResponse(
Guid Id,
Guid FK_RideId,
Guid FK_OrderId,
Guid StoreId,
string StoreName,
int Sequence,
DeliveryTaskStatus Status,
bool CustomerConfirmed,
DateTime? ConfirmedAt,
string? Note,
List<DeliveryTaskItemResponse> Items,
DateTime CreatedAt);
public record SetQuantitiesRequest(List<DeliveryTaskItemQuantity> Items);
public record DeliveryTaskItemQuantity(Guid ItemId, int QuantityFulfilled);
public record FailTaskRequest(string Note);
@@ -0,0 +1,38 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskRepository
{
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence)
.ToListAsync();
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task);
public void Update(DeliveryTask task) =>
context.DeliveryTasks.Update(task);
}
@@ -0,0 +1,224 @@
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id,
t.FK_RideId,
t.FK_OrderId,
t.Order.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty,
t.Sequence,
t.Status,
t.CustomerConfirmed,
t.ConfirmedAt,
t.Note,
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId,
i.Item?.Name ?? string.Empty,
i.Quantity,
i.QuantityFulfilled,
i.UnitPrice)).ToList(),
t.CreatedAt);
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
if (ride.RideType != RideType.Delivery)
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var created = new List<DeliveryTask>();
int sequence = 1;
foreach (var order in orders)
{
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
continue;
var task = new DeliveryTask
{
FK_RideId = rideId,
FK_OrderId = order.Id,
Sequence = sequence++,
Status = DeliveryTaskStatus.Pending
};
created.Add(task);
}
if (created.Count == 0)
return new List<DeliveryTaskResponse>();
await uow.BeginAsync(ct);
try
{
foreach (var task in created)
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)
{
var tasks = await repo.GetByRideIdAsync(rideId);
return tasks.Select(ToResponse).ToList();
}
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
{
var task = await repo.GetByIdAsync(id);
return task is null ? null : ToResponse(task);
}
public async Task<DeliveryTaskResponse> SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Quantities can only be set for a Pending task");
foreach (var input in request.Items)
{
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > orderItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
orderItem.QuantityFulfilled = input.QuantityFulfilled;
}
await uow.BeginAsync(ct);
try
{
repo.Update(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var order = task.Order;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
if (fullyDelivered)
order.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId)
?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}");
if (vehicleBalance.Quantity < item.QuantityFulfilled)
throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}");
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,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_OrderId
});
}
repo.Update(task);
orderRepo.Update(order);
if (fullyDelivered)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> FailAsync(Guid taskId, string note, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be failed");
task.Status = DeliveryTaskStatus.Failed;
task.Note = note;
await uow.BeginAsync(ct);
try
{
repo.Update(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(task);
}
}
@@ -0,0 +1,12 @@
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public interface IDeliveryTaskRepository
{
Task<DeliveryTask?> GetByIdAsync(Guid id);
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
Task AddAsync(DeliveryTask task);
void Update(DeliveryTask task);
}
@@ -0,0 +1,28 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverComplianceConfig;
[ApiController]
[Route("api/driver-compliance-config")]
[Authorize]
public class DriverComplianceConfigController(DriverComplianceConfigService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "compliance.manage")]
public async Task<IActionResult> Get()
{
var result = await service.GetAsync();
return result is null ? NoContent() : Ok(result);
}
[HttpPut]
[Authorize(Policy = "compliance.manage")]
public async Task<IActionResult> Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var result = await service.UpsertAsync(request, userId, ct);
return Ok(result);
}
}
@@ -0,0 +1,12 @@
namespace Distribution.Features.DriverComplianceConfig;
public record DriverComplianceConfigResponse(
Guid Id,
bool EnforceLicenseExpiry,
bool EnforceMedicalClearance,
DateTime UpdatedAt,
Guid UpdatedBy);
public record UpdateDriverComplianceConfigRequest(
bool EnforceLicenseExpiry,
bool EnforceMedicalClearance);
@@ -0,0 +1,16 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DriverComplianceConfig;
public class DriverComplianceConfigRepository(AppDBContext context) : IDriverComplianceConfigRepository
{
public async Task<Models.DriverComplianceConfig?> GetAsync() =>
await context.DriverComplianceConfigs.FirstOrDefaultAsync();
public async Task AddAsync(Models.DriverComplianceConfig config) =>
await context.DriverComplianceConfigs.AddAsync(config);
public void Update(Models.DriverComplianceConfig config) =>
context.DriverComplianceConfigs.Update(config);
}
@@ -0,0 +1,62 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.DriverComplianceConfig;
public class DriverComplianceConfigService(IDriverComplianceConfigRepository repo, IUnitOfWork uow)
{
public async Task<DriverComplianceConfigResponse?> GetAsync()
{
var config = await repo.GetAsync();
return config is null
? null
: new DriverComplianceConfigResponse(
config.Id,
config.EnforceLicenseExpiry,
config.EnforceMedicalClearance,
config.UpdatedAt,
config.UpdatedBy);
}
public async Task<DriverComplianceConfigResponse> UpsertAsync(UpdateDriverComplianceConfigRequest request, Guid updatedBy, CancellationToken ct = default)
{
var config = await repo.GetAsync();
await uow.BeginAsync(ct);
try
{
if (config is null)
{
config = new global::Distribution.Models.DriverComplianceConfig
{
EnforceLicenseExpiry = request.EnforceLicenseExpiry,
EnforceMedicalClearance = request.EnforceMedicalClearance,
UpdatedBy = updatedBy,
UpdatedAt = DateTime.Now
};
await repo.AddAsync(config);
}
else
{
config.EnforceLicenseExpiry = request.EnforceLicenseExpiry;
config.EnforceMedicalClearance = request.EnforceMedicalClearance;
config.UpdatedBy = updatedBy;
config.UpdatedAt = DateTime.Now;
repo.Update(config);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new DriverComplianceConfigResponse(
config.Id,
config.EnforceLicenseExpiry,
config.EnforceMedicalClearance,
config.UpdatedAt,
config.UpdatedBy);
}
}
@@ -0,0 +1,8 @@
namespace Distribution.Features.DriverComplianceConfig;
public interface IDriverComplianceConfigRepository
{
Task<Models.DriverComplianceConfig?> GetAsync();
Task AddAsync(Models.DriverComplianceConfig config);
void Update(Models.DriverComplianceConfig config);
}
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverProfile;
[ApiController]
[Route("api/driver-profiles")]
[Authorize]
public class DriverProfileController(DriverProfileService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "driver_profiles.view")]
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
[Authorize(Policy = "driver_profiles.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "driver_profiles.create")]
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "driver_profiles.update")]
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "driver_profiles.deactivate")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var success = await service.DeactivateAsync(id, ct);
return success ? NoContent() : NotFound();
}
}
@@ -0,0 +1,22 @@
namespace Distribution.Features.DriverProfile;
public record DriverProfileResponse(
Guid Id,
Guid UserId,
string UserFullName,
string LicenseNumber,
DateTime LicenseExpiry,
DateTime MedicalClearanceExpiry,
bool IsActive,
DateTime CreatedAt);
public record CreateDriverProfileRequest(
Guid FK_UserId,
string LicenseNumber,
DateTime LicenseExpiry,
DateTime MedicalClearanceExpiry);
public record UpdateDriverProfileRequest(
string LicenseNumber,
DateTime LicenseExpiry,
DateTime MedicalClearanceExpiry);
@@ -0,0 +1,40 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DriverProfile;
public class DriverProfileRepository(AppDBContext context) : IDriverProfileRepository
{
public async Task<global::Distribution.Models.DriverProfile?> GetByIdAsync(Guid id) =>
await context.DriverProfiles
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == id);
public async Task<global::Distribution.Models.DriverProfile?> GetByUserIdAsync(Guid userId) =>
await context.DriverProfiles
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.FK_UserId == userId);
public async Task<List<global::Distribution.Models.DriverProfile>> GetAllAsync(int page, int pageSize) =>
await context.DriverProfiles
.Include(p => p.User)
.Where(p => p.IsActive)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync() =>
await context.DriverProfiles.CountAsync(p => p.IsActive);
public async Task AddAsync(global::Distribution.Models.DriverProfile profile) =>
await context.DriverProfiles.AddAsync(profile);
public void Update(global::Distribution.Models.DriverProfile profile) =>
context.DriverProfiles.Update(profile);
public void Deactivate(global::Distribution.Models.DriverProfile profile)
{
profile.IsActive = false;
context.DriverProfiles.Update(profile);
}
}
@@ -0,0 +1,100 @@
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
namespace Distribution.Features.DriverProfile;
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow)
{
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
{
var total = await repo.GetCountAsync();
var items = await repo.GetAllAsync(page, pageSize);
return new PagedResponse<DriverProfileResponse>(page, pageSize, total,
items.Select(ToResponse).ToList());
}
public async Task<DriverProfileResponse?> GetByIdAsync(Guid id)
{
var profile = await repo.GetByIdAsync(id);
return profile is null || !profile.IsActive ? null : ToResponse(profile);
}
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
{
var existing = await repo.GetByUserIdAsync(request.FK_UserId);
if (existing is not null && existing.IsActive)
throw new InvalidOperationException("An active driver profile already exists for this user");
var profile = new global::Distribution.Models.DriverProfile
{
FK_UserId = request.FK_UserId,
LicenseNumber = request.LicenseNumber,
LicenseExpiry = request.LicenseExpiry,
MedicalClearanceExpiry = request.MedicalClearanceExpiry
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(profile);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
var saved = await repo.GetByIdAsync(profile.Id);
return ToResponse(saved!);
}
public async Task<DriverProfileResponse?> UpdateAsync(Guid id, UpdateDriverProfileRequest request, CancellationToken ct = default)
{
var profile = await repo.GetByIdAsync(id);
if (profile is null || !profile.IsActive) return null;
profile.LicenseNumber = request.LicenseNumber;
profile.LicenseExpiry = request.LicenseExpiry;
profile.MedicalClearanceExpiry = request.MedicalClearanceExpiry;
await uow.BeginAsync(ct);
try
{
repo.Update(profile);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(profile);
}
public async Task<bool> DeactivateAsync(Guid id, CancellationToken ct = default)
{
var profile = await repo.GetByIdAsync(id);
if (profile is null || !profile.IsActive) return false;
await uow.BeginAsync(ct);
try
{
repo.Deactivate(profile);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
private static DriverProfileResponse ToResponse(global::Distribution.Models.DriverProfile p) =>
new(p.Id, p.FK_UserId, p.User?.FullName ?? string.Empty,
p.LicenseNumber, p.LicenseExpiry, p.MedicalClearanceExpiry,
p.IsActive, p.CreatedAt);
}
@@ -0,0 +1,12 @@
namespace Distribution.Features.DriverProfile;
public interface IDriverProfileRepository
{
Task<global::Distribution.Models.DriverProfile?> GetByIdAsync(Guid id);
Task<global::Distribution.Models.DriverProfile?> GetByUserIdAsync(Guid userId);
Task<List<global::Distribution.Models.DriverProfile>> GetAllAsync(int page, int pageSize);
Task<int> GetCountAsync();
Task AddAsync(global::Distribution.Models.DriverProfile profile);
void Update(global::Distribution.Models.DriverProfile profile);
void Deactivate(global::Distribution.Models.DriverProfile profile);
}
@@ -0,0 +1,33 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Grn;
[ApiController]
[Authorize]
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/goods-receipts/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace Distribution.Features.Grn;
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
public record GrnItemResponse(
Guid Id,
Guid PurchaseOrderItemId,
Guid ItemId,
string Sku,
string ItemName,
int AcceptedQuantity,
int RejectedQuantity);
public record GrnResponse(
Guid Id,
Guid PurchaseOrderId,
Guid ReceivedByUserId,
Guid WarehouseId,
string WarehouseName,
DateTime ReceivedAt,
DateTime CreatedAt,
List<GrnItemResponse> Items);
@@ -0,0 +1,35 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Grn;
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)
.FirstOrDefaultAsync(gr => gr.Id == id);
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)
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
.OrderByDescending(gr => gr.ReceivedAt)
.ToListAsync();
public async Task AddAsync(Models.GoodsReceipt entity) =>
await context.GoodsReceipts.AddAsync(entity);
public async Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts.AnyAsync(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive);
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)
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
}
+137
View File
@@ -0,0 +1,137 @@
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Grn;
public class GrnService(
IGrnRepository repo,
IPoRepository poRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
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 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());
public async Task<GrnResponse?> GetByIdAsync(Guid id)
{
var gr = await repo.GetByIdAsync(id);
if (gr is null || !gr.IsActive) return null;
return Map(gr);
}
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
/// <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.
/// </summary>
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{
var po = await poRepo.GetByIdAsync(purchaseOrderId)
?? throw new InvalidOperationException("Purchase order not found.");
if (!po.IsActive)
throw new InvalidOperationException("Purchase order not found.");
if (po.Status is not (PurchaseOrderStatus.Issued or PurchaseOrderStatus.PartiallyReceived))
throw new InvalidOperationException(
$"Cannot receive goods against a purchase order in {po.Status} status.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item.");
var goodsReceipt = new Models.GoodsReceipt
{
FK_PurchaseOrderId = po.Id,
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now
};
foreach (var line in request.Items)
{
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)
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.");
poItem.ReceivedQuantity += line.AcceptedQuantity;
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
{
FK_PurchaseOrderItemId = poItem.Id,
AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity
});
}
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
po.Status = activeLines.Count > 0 && activeLines.All(i => i.ReceivedQuantity >= i.Quantity)
? PurchaseOrderStatus.Received
: PurchaseOrderStatus.PartiallyReceived;
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(goodsReceipt);
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 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)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = poItem.FK_ItemId,
LocationId = request.WarehouseId,
LocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity
});
}
else
{
balance.Quantity += line.AcceptedQuantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = poItem.FK_ItemId,
ToLocationId = request.WarehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
poRepo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
return Map(saved!);
}
}
@@ -0,0 +1,14 @@
namespace Distribution.Features.Grn;
public interface IGrnRepository
{
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
Task AddAsync(Models.GoodsReceipt entity);
/// <summary>True if at least one GoodsReceipt exists against this Po (createPurchaseReturn guard).</summary>
Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId);
/// <summary>Sum of AcceptedQuantity per ItemId across all receipts for a Po.</summary>
Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId);
}
@@ -3,6 +3,7 @@ namespace Distribution.Features.Item;
public interface IItemRepository
{
Task<Models.Item?> GetByIdAsync(Guid id);
Task<Models.Item?> GetBySkuAsync(string sku);
Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null);
Task<bool> SkuExistsAsync(string sku, Guid? excludeId = null);
Task<Models.Item?> GetByExternalErpItemIdAsync(int erpId);
@@ -15,6 +15,9 @@ public class ItemRepository(AppDBContext context) : IItemRepository
.Include(i => i.Conversions).ThenInclude(c => c.ToUom)
.FirstOrDefaultAsync(i => i.Id == id);
public async Task<Models.Item?> GetBySkuAsync(string sku) =>
await context.Items.FirstOrDefaultAsync(i => i.Sku == sku && i.IsActive);
public async Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null)
{
var query = context.Items
@@ -0,0 +1,16 @@
using Distribution.Models;
namespace Distribution.Features.LoadingEvent;
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<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
Task AddAsync(LoadingSheet sheet);
void Update(LoadingSheet sheet);
void RemoveItems(IEnumerable<LoadingSheetItem> items);
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
}
@@ -0,0 +1,113 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.LoadingEvent;
[ApiController]
[Route("api/loading-sheets")]
[Authorize]
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "loading.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] LoadingSheetStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(rideId, status, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
[Authorize(Policy = "loading.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/items")]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.SetItemsAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-dispatch")]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/receipt-items")]
[Authorize(Policy = "loading.receive")]
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.SetReceiptItemsAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-receipt")]
[Authorize(Policy = "loading.receive")]
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmReceiptAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/resolve")]
[Authorize(Policy = "loading.resolve")]
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ResolveAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,37 @@
using Distribution.Models;
namespace Distribution.Features.LoadingEvent;
public record LoadingSheetResponse(
Guid Id,
Guid FK_RideId,
Guid FK_SourceWarehouseId,
string WarehouseName,
LoadingSheetStatus Status,
DateTime? DispatchConfirmedAt,
Guid? DispatchConfirmedBy,
DateTime? ReceiptConfirmedAt,
Guid? ReceiptConfirmedBy,
DateTime CreatedAt,
List<LoadingSheetItemResponse> Items);
public record LoadingSheetItemResponse(
Guid Id,
Guid FK_ItemId,
string ItemName,
decimal QuantityDispatched,
decimal? QuantityReceived,
decimal? QuantityResolved);
public record CreateLoadingSheetRequest(Guid FK_RideId);
public record SetItemsRequest(List<SetItemInput> Items);
public record SetItemInput(Guid FK_ItemId, decimal Quantity);
public record SetReceiptItemsRequest(List<ReceiptItemInput> Items);
public record ReceiptItemInput(Guid FK_ItemId, decimal QuantityReceived);
public record ConfirmReceiptRequest(int Meterstart);
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
@@ -0,0 +1,63 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.LoadingEvent;
public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetRepository
{
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
await context.LoadingSheets
.Include(s => s.SourceWarehouse)
.Include(s => s.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(s => s.Id == id);
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
await context.LoadingSheets
.FirstOrDefaultAsync(s =>
s.FK_RideId == rideId &&
s.Status != LoadingSheetStatus.ReceiptConfirmed &&
s.Status != LoadingSheetStatus.Resolved);
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
await context.LoadingSheets
.Include(s => s.SourceWarehouse)
.Where(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status) =>
await context.LoadingSheets
.CountAsync(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status));
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
{
var groups = await context.OrderItems
.Where(oi => oi.IsActive
&& oi.Order.IsActive
&& oi.Order.Store.FK_RouteId == routeId
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
.ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
public async Task AddAsync(LoadingSheet sheet) =>
await context.LoadingSheets.AddAsync(sheet);
public void Update(LoadingSheet sheet) =>
context.LoadingSheets.Update(sheet);
public void RemoveItems(IEnumerable<LoadingSheetItem> items) =>
context.LoadingSheetItems.RemoveRange(items);
public async Task AddItemsAsync(IEnumerable<LoadingSheetItem> items) =>
await context.LoadingSheetItems.AddRangeAsync(items);
}
@@ -0,0 +1,284 @@
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.LoadingEvent;
public class LoadingSheetService(
ILoadingSheetRepository repo,
IRideRepository rideRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize)
{
var total = await repo.GetCountAsync(rideId, status);
var items = await repo.GetAllAsync(rideId, status, page, pageSize);
return new PagedResponse<LoadingSheetResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<LoadingSheetResponse?> GetByIdAsync(Guid id)
{
var sheet = await repo.GetByIdAsync(id);
return sheet is null ? null : ToResponse(sheet);
}
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException("A loading sheet can only be created for an Open ride");
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
if (existing is not null)
throw new InvalidOperationException("This ride already has an active loading sheet");
var sheet = new LoadingSheet
{
FK_RideId = ride.Id,
FK_SourceWarehouseId = ride.FK_SourceWarehouseId,
Status = LoadingSheetStatus.Pending
};
if (ride.RideType == RideType.Delivery)
{
var orderItems = await repo.GetAggregatedOrderItemsByRouteIdAsync(ride.FK_RouteId);
foreach (var (itemId, qty) in orderItems)
sheet.Items.Add(new LoadingSheetItem { FK_ItemId = itemId, QuantityDispatched = qty });
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(sheet.Id);
return ToResponse(saved!);
}
public async Task<LoadingSheetResponse> SetItemsAsync(Guid id, SetItemsRequest request, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException("Items can only be changed while the sheet is Pending");
await uow.BeginAsync(ct);
try
{
repo.RemoveItems(sheet.Items);
await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem
{
FK_LoadingSheetId = sheet.Id,
FK_ItemId = i.FK_ItemId,
QuantityDispatched = i.Quantity
}));
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(sheet.Id);
return ToResponse(saved!);
}
public async Task<LoadingSheetResponse> ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status");
if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero");
sheet.Status = LoadingSheetStatus.DispatchConfirmed;
sheet.DispatchConfirmedAt = DateTime.Now;
sheet.DispatchConfirmedBy = userId;
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> SetReceiptItemsAsync(Guid id, SetReceiptItemsRequest request, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException("Receipt quantities can only be set after dispatch is confirmed");
foreach (var input in request.Items)
{
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
item.QuantityReceived = input.QuantityReceived;
}
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> ConfirmReceiptAsync(Guid id, ConfirmReceiptRequest request, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot confirm receipt for a sheet in {sheet.Status} status");
if (sheet.Items.Any(i => !i.QuantityReceived.HasValue))
throw new InvalidOperationException("All items must have a received quantity before confirming receipt");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> ResolveAsync(Guid id, ResolveDiscrepancyRequest request, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var input in request.Items)
{
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
if (input.QuantityResolved < 0)
throw new InvalidOperationException("Resolved quantity cannot be negative");
item.QuantityResolved = input.QuantityResolved;
}
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue))
throw new InvalidOperationException("All items must have a resolved quantity");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
{
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
{
var warehouseBalance = await stockRepo.GetBalanceAsync(itemId, sheet.FK_SourceWarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {itemId}");
if (warehouseBalance.Quantity < qty)
throw new InvalidOperationException($"Insufficient warehouse stock for item {itemId}");
warehouseBalance.Quantity -= qty;
warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId);
if (vehicleBalance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = itemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = qty
});
}
else
{
vehicleBalance.Quantity += qty;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = itemId,
FromLocationId = sheet.FK_SourceWarehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = qty,
Type = MovementType.Load,
ReferenceId = sheet.Id,
CreatedBy = userId
});
}
}
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId,
s.SourceWarehouse?.Name ?? string.Empty,
s.Status,
s.DispatchConfirmedAt, s.DispatchConfirmedBy,
s.ReceiptConfirmedAt, s.ReceiptConfirmedBy,
s.CreatedAt,
s.Items.Select(i => new LoadingSheetItemResponse(
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList());
}
@@ -4,10 +4,12 @@ public interface IOrderRepository
{
Task<Models.Order?> GetByIdAsync(Guid id);
Task<List<Models.Order>> GetAllAsync();
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
}
+12 -4
View File
@@ -33,16 +33,24 @@ public class OrderController(OrderService service) : ControllerBase
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "orders.update")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpDelete("{id:guid}")]
+8 -5
View File
@@ -1,18 +1,21 @@
using Distribution.Models;
namespace Distribution.Features.Order;
public record OrderItemRequest(Guid ItemId, int Quantity);
public record CreateOrderRequest(Guid SaleId);
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
public record UpdateOrderRequest(OrderStatus Status);
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
public record OrderItemResponse(Guid ItemId, int Quantity);
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid SaleId,
DateTime OrderDate,
Guid StoreId,
decimal TotalAmount,
OrderStatus Status,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -1,4 +1,5 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Order;
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task<List<Models.Order>> GetAllAsync() =>
await context.Orders.ToListAsync();
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
public async Task AddAsync(Models.Order entity) =>
await context.Orders.AddAsync(entity);
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public void Remove(Models.Order entity)
{
entity.Status = Models.OrderStatus.Cancelled;
entity.IsActive = false;
context.Orders.Update(entity);
}
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
await context.OrderItems.AddRangeAsync(items);
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Include(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
}
+51 -31
View File
@@ -1,15 +1,30 @@
using Distribution.Features.Sale;
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.Store;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Order;
public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
public class OrderService(
IOrderRepository repo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity)).ToList(),
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
@@ -61,34 +76,15 @@ public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserSe
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var sale = await saleRepo.GetByIdAsync(request.SaleId);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var order = new Models.Order
{
FK_StoreId = request.StoreId,
OrderDate = request.OrderDate,
TotalAmount = request.TotalAmount
};
var orderItems = request.Items.Select(i => new Models.OrderItem
{
FK_OrderId = order.Id,
FK_ItemId = i.ItemId,
Quantity = i.Quantity
}).ToList();
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
?? throw new InvalidOperationException("Orders are created automatically from sales");
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(order);
await repo.AddItemsAsync(orderItems);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(order, orderItems);
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
return ToResponse(existingOrder, items);
}
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
@@ -96,12 +92,26 @@ public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserSe
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
order.OrderDate = request.OrderDate;
order.TotalAmount = request.TotalAmount;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order");
order.Status = request.Status;
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
sale.Status = request.Status switch
{
OrderStatus.Fulfilled => SaleStatus.Completed,
OrderStatus.Cancelled => SaleStatus.Cancelled,
_ => SaleStatus.Pending
};
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
await uow.BeginAsync(ct);
try
{
repo.Update(order);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch
@@ -109,6 +119,7 @@ public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserSe
await uow.RollbackAsync(ct);
throw;
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
@@ -118,10 +129,19 @@ public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserSe
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
await uow.BeginAsync(ct);
try
{
repo.Remove(order);
if (sale is not null)
{
sale.Status = SaleStatus.Cancelled;
sale.CompletedAt = null;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch
@@ -0,0 +1,9 @@
using Distribution.Models;
namespace Distribution.Features.Payment;
public interface IPaymentRepository
{
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Payment payment);
}
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Authorize]
[Route("api/sales/{saleId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetBySale(Guid saleId)
{
var result = await service.GetBySaleIdAsync(saleId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(saleId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,15 @@
using Distribution.Models;
namespace Distribution.Features.Payment;
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record PaymentResponse(
Guid Id,
Guid SaleId,
Guid StoreId,
decimal Amount,
PaymentMethod Method,
DateTime CollectedAt,
Guid CollectedBy,
DateTime CreatedAt);
@@ -0,0 +1,17 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Payment;
public class PaymentRepository(AppDBContext context) : IPaymentRepository
{
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
await context.Payments
.Where(p => p.FK_SaleId == saleId && p.IsActive)
.OrderBy(p => p.CollectedAt)
.ToListAsync();
public async Task AddAsync(Models.Payment payment) =>
await context.Payments.AddAsync(payment);
}
@@ -0,0 +1,67 @@
using Distribution.Features.Sale;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Payment;
public class PaymentService(
IPaymentRepository repo,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
var payments = await repo.GetBySaleIdAsync(saleId);
return payments.Select(ToResponse).ToList();
}
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var payment = new Models.Payment
{
FK_SaleId = saleId,
FK_StoreId = sale.FK_StoreId,
Amount = request.Amount,
Method = request.Method,
CollectedAt = DateTime.Now,
CollectedBy = userId
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(payment);
var allPayments = await repo.GetBySaleIdAsync(saleId);
var total = allPayments.Sum(p => p.Amount) + request.Amount;
sale.PaymentStatus = total == 0
? PaymentStatus.Pending
: total < sale.TotalAmount
? PaymentStatus.PartiallyPaid
: PaymentStatus.Paid;
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(payment);
}
}
@@ -0,0 +1,27 @@
namespace Distribution.Features.PriceList;
public interface IPriceListRepository
{
Task<Models.PriceList?> GetByIdAsync(Guid id);
Task<List<Models.PriceList>> GetAllAsync(Guid? regionId = null);
Task AddAsync(Models.PriceList entity);
void Update(Models.PriceList entity);
void Remove(Models.PriceList entity);
Task<Models.PriceListItem?> GetItemAsync(Guid priceListId, Guid itemId);
Task AddItemAsync(Models.PriceListItem entity);
void UpdateItem(Models.PriceListItem entity);
void RemoveItem(Models.PriceListItem entity);
/// <summary>
/// Active PriceListItem rows for the given item and region (region null = global lists only),
/// whose parent PriceList is active and effective on orderDate. Most recently created first.
/// </summary>
Task<List<Models.PriceListItem>> GetActiveItemsAsync(Guid itemId, Guid? regionId, DateTime orderDate);
/// <summary>
/// Active PriceListItem rows for itemId under any PriceList with the given RegionId (null = global),
/// whose date range overlaps [effectiveFrom, effectiveTo], excluding a given PriceList.
/// </summary>
Task<bool> HasOverlapAsync(Guid itemId, Guid? regionId, DateTime effectiveFrom, DateTime effectiveTo, Guid? excludePriceListId);
}
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PriceList;
[ApiController]
[Route("api/price-lists")]
[Authorize]
public class PriceListController(PriceListService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreatePriceListRequest 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, UpdatePriceListRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/items/{sku}")]
public async Task<IActionResult> UpsertItem(Guid id, string sku, UpsertPriceListItemRequest request, CancellationToken ct)
{
try
{
var result = await service.UpsertItemAsync(id, sku, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}/items/{sku}")]
public async Task<IActionResult> RemoveItem(Guid id, string sku, CancellationToken ct)
{
var result = await service.RemoveItemAsync(id, sku, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,25 @@
namespace Distribution.Features.PriceList;
public record CreatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo);
public record UpdatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo);
public record UpsertPriceListItemRequest(decimal Price, decimal? MinPrice = null);
public record PriceListItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, decimal Price, decimal? MinPrice);
/// <summary>
/// A resolved price with its negotiable floor. MinPrice null = no negotiation, Price is fixed.
/// </summary>
public record PriceResolution(decimal Price, decimal? MinPrice);
public record PriceListResponse(
Guid Id,
string Name,
Guid? RegionId,
string? RegionName,
DateTime EffectiveFrom,
DateTime EffectiveTo,
bool IsActive,
DateTime CreatedAt,
List<PriceListItemResponse> Items);
@@ -0,0 +1,76 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.PriceList;
public class PriceListRepository(AppDBContext context) : IPriceListRepository
{
public async Task<Models.PriceList?> GetByIdAsync(Guid id) =>
await context.PriceLists
.Include(pl => pl.Region)
.Include(pl => pl.Items.Where(i => i.IsActive)).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(pl => pl.Id == id);
public async Task<List<Models.PriceList>> GetAllAsync(Guid? regionId = null)
{
var query = context.PriceLists
.Include(pl => pl.Region)
.Where(pl => pl.IsActive);
if (regionId.HasValue) query = query.Where(pl => pl.FK_RegionId == regionId);
return await query.ToListAsync();
}
public async Task AddAsync(Models.PriceList entity) =>
await context.PriceLists.AddAsync(entity);
public void Update(Models.PriceList entity) =>
context.PriceLists.Update(entity);
public void Remove(Models.PriceList entity)
{
entity.IsActive = false;
context.PriceLists.Update(entity);
}
public async Task<Models.PriceListItem?> GetItemAsync(Guid priceListId, Guid itemId) =>
await context.PriceListItems
.FirstOrDefaultAsync(i => i.FK_PriceListId == priceListId && i.FK_ItemId == itemId && i.IsActive);
public async Task AddItemAsync(Models.PriceListItem entity) =>
await context.PriceListItems.AddAsync(entity);
public void UpdateItem(Models.PriceListItem entity) =>
context.PriceListItems.Update(entity);
public void RemoveItem(Models.PriceListItem entity)
{
entity.IsActive = false;
context.PriceListItems.Update(entity);
}
public async Task<List<Models.PriceListItem>> GetActiveItemsAsync(Guid itemId, Guid? regionId, DateTime orderDate) =>
await context.PriceListItems
.Include(i => i.PriceList)
.Where(i => i.IsActive
&& i.FK_ItemId == itemId
&& i.PriceList.IsActive
&& i.PriceList.FK_RegionId == regionId
&& i.PriceList.EffectiveFrom <= orderDate
&& i.PriceList.EffectiveTo >= orderDate)
.OrderByDescending(i => i.PriceList.CreatedAt)
.ToListAsync();
public async Task<bool> HasOverlapAsync(Guid itemId, Guid? regionId, DateTime effectiveFrom, DateTime effectiveTo, Guid? excludePriceListId) =>
await context.PriceListItems
.Include(i => i.PriceList)
.Where(i => i.IsActive
&& i.FK_ItemId == itemId
&& i.PriceList.IsActive
&& i.PriceList.FK_RegionId == regionId
&& (excludePriceListId == null || i.FK_PriceListId != excludePriceListId)
&& i.PriceList.EffectiveFrom <= effectiveTo
&& i.PriceList.EffectiveTo >= effectiveFrom)
.AnyAsync();
}
@@ -0,0 +1,171 @@
using Distribution.Features.Item;
using Distribution.Infra.UoW;
namespace Distribution.Features.PriceList;
public class PriceListService(IPriceListRepository repo, IItemRepository itemRepo, IUnitOfWork uow)
{
private static PriceListItemResponse MapItem(Models.PriceListItem i) =>
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Price, i.MinPrice);
private static PriceListResponse MapDetail(Models.PriceList pl) =>
new(pl.Id, pl.Name, pl.FK_RegionId, pl.Region?.Name,
pl.EffectiveFrom, pl.EffectiveTo, pl.IsActive, pl.CreatedAt,
pl.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<List<PriceListResponse>> GetAllAsync(Guid? regionId = null)
{
var lists = await repo.GetAllAsync(regionId);
return lists.Select(pl => new PriceListResponse(
pl.Id, pl.Name, pl.FK_RegionId, pl.Region?.Name,
pl.EffectiveFrom, pl.EffectiveTo, pl.IsActive, pl.CreatedAt, [])).ToList();
}
public async Task<PriceListResponse?> GetByIdAsync(Guid id)
{
var pl = await repo.GetByIdAsync(id);
if (pl is null || !pl.IsActive) return null;
return MapDetail(pl);
}
public async Task<PriceListResponse> CreateAsync(CreatePriceListRequest request, CancellationToken ct = default)
{
var priceList = new Models.PriceList
{
Name = request.Name,
FK_RegionId = request.RegionId,
EffectiveFrom = request.EffectiveFrom,
EffectiveTo = request.EffectiveTo
};
await uow.BeginAsync(ct);
try { await repo.AddAsync(priceList); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(priceList.Id))!;
}
public async Task<PriceListResponse?> UpdateAsync(Guid id, UpdatePriceListRequest request, CancellationToken ct = default)
{
var priceList = await repo.GetByIdAsync(id);
if (priceList is null || !priceList.IsActive) return null;
foreach (var item in priceList.Items.Where(i => i.IsActive))
{
if (await repo.HasOverlapAsync(item.FK_ItemId, request.RegionId, request.EffectiveFrom, request.EffectiveTo, id))
throw new InvalidOperationException(
$"Overlapping price list already exists for item '{item.Item.Sku}' in the same region for the requested date range.");
}
priceList.Name = request.Name;
priceList.FK_RegionId = request.RegionId;
priceList.EffectiveFrom = request.EffectiveFrom;
priceList.EffectiveTo = request.EffectiveTo;
await uow.BeginAsync(ct);
try { repo.Update(priceList); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(id))!;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var priceList = await repo.GetByIdAsync(id);
if (priceList is null || !priceList.IsActive) return false;
await uow.BeginAsync(ct);
try
{
foreach (var item in priceList.Items.Where(i => i.IsActive))
repo.RemoveItem(item);
repo.Remove(priceList);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return true;
}
public async Task<PriceListResponse?> UpsertItemAsync(Guid priceListId, string sku, UpsertPriceListItemRequest request, CancellationToken ct = default)
{
var priceList = await repo.GetByIdAsync(priceListId);
if (priceList is null || !priceList.IsActive) return null;
var item = await itemRepo.GetBySkuAsync(sku);
if (item is null) return null;
if (request.MinPrice.HasValue && request.MinPrice.Value > request.Price)
throw new InvalidOperationException(
$"MinPrice ({request.MinPrice}) cannot exceed Price ({request.Price}) for item '{sku}'.");
if (await repo.HasOverlapAsync(item.Id, priceList.FK_RegionId, priceList.EffectiveFrom, priceList.EffectiveTo, priceListId))
throw new InvalidOperationException(
$"Overlapping price list already exists for item '{sku}' in the same region for this date range.");
var existing = await repo.GetItemAsync(priceListId, item.Id);
await uow.BeginAsync(ct);
try
{
if (existing is null)
{
await repo.AddItemAsync(new Models.PriceListItem
{
FK_PriceListId = priceListId,
FK_ItemId = item.Id,
Price = request.Price,
MinPrice = request.MinPrice
});
}
else
{
existing.Price = request.Price;
existing.MinPrice = request.MinPrice;
repo.UpdateItem(existing);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(priceListId))!;
}
public async Task<PriceListResponse?> RemoveItemAsync(Guid priceListId, string sku, CancellationToken ct = default)
{
var priceList = await repo.GetByIdAsync(priceListId);
if (priceList is null || !priceList.IsActive) return null;
var item = await itemRepo.GetBySkuAsync(sku);
if (item is null) return null;
var existing = await repo.GetItemAsync(priceListId, item.Id);
if (existing is null) return null;
await uow.BeginAsync(ct);
try { repo.RemoveItem(existing); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(priceListId))!;
}
/// <summary>
/// applyPricingAndDiscounts (price-list half): region-scoped active price list -> global
/// active price list -> Item.SalePrice -> null if none apply. The returned PriceResolution
/// carries MinPrice so the caller can validate a negotiated UnitPrice against the band;
/// the Item.SalePrice fallback has no MinPrice (fixed, non-negotiable).
/// </summary>
public async Task<PriceResolution?> ResolvePriceAsync(Guid itemId, Guid? regionId, decimal? itemSalePrice, DateTime orderDate)
{
if (regionId.HasValue)
{
var regionMatches = await repo.GetActiveItemsAsync(itemId, regionId, orderDate);
if (regionMatches.Count > 0) return new PriceResolution(regionMatches[0].Price, regionMatches[0].MinPrice);
}
var globalMatches = await repo.GetActiveItemsAsync(itemId, null, orderDate);
if (globalMatches.Count > 0) return new PriceResolution(globalMatches[0].Price, globalMatches[0].MinPrice);
return itemSalePrice.HasValue ? new PriceResolution(itemSalePrice.Value, null) : null;
}
}
@@ -0,0 +1,19 @@
using Distribution.Models;
namespace Distribution.Features.Po;
public interface IPoRepository
{
Task<Models.PurchaseOrder?> GetByIdAsync(Guid id);
Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status);
Task AddAsync(Models.PurchaseOrder entity);
void Update(Models.PurchaseOrder entity);
Task AddItemAsync(PurchaseOrderItem entity);
/// <summary>True if any active (non-cancelled, non-closed-and-void) PurchaseOrder references this supplier.</summary>
Task<bool> HasActiveForSupplierAsync(Guid supplierId);
/// <summary>All active PurchaseOrders for a supplier, with items, used by evaluateSupplier.</summary>
Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId);
}
@@ -0,0 +1,97 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Po;
[ApiController]
[Route("api/purchase-orders")]
[Authorize]
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) =>
Ok(await service.GetAllAsync(supplierId, status));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreatePoRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdatePoRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/close")]
public async Task<IActionResult> Close(Guid id, CancellationToken ct)
{
try
{
var result = await service.CloseAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancelPoRequest request, CancellationToken ct)
{
try
{
var result = await service.CancelAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/items/{itemId:guid}/cancel")]
public async Task<IActionResult> CancelLine(Guid id, Guid itemId, CancelPoLineRequest request, CancellationToken ct)
{
try
{
var result = await service.CancelLineAsync(id, itemId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/match-invoice")]
public async Task<IActionResult> MatchInvoice(Guid id, [FromQuery] Guid? invoiceId, [FromQuery] Guid? grnId)
{
var result = await service.MatchInvoiceToPoAsync(id, invoiceId, grnId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/resolve-variance")]
public async Task<IActionResult> ResolveVariance(Guid id, ResolveInvoiceVarianceRequest request, CancellationToken ct)
{
try
{
var result = await service.ResolveInvoiceVarianceAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
@@ -0,0 +1,54 @@
using Distribution.Models;
namespace Distribution.Features.Po;
public record PoItemRequest(Guid ItemId, int Quantity, decimal UnitCost);
public record CreatePoRequest(Guid SupplierId, List<PoItemRequest> Items);
/// <summary>
/// Update line: PurchaseOrderItemId set = update an existing unreceived line (Quantity/UnitCost),
/// null = add a new line to the Po. Only valid while the Po is Issued.
/// </summary>
public record UpdatePoLineRequest(Guid? PurchaseOrderItemId, Guid ItemId, int Quantity, decimal UnitCost);
public record UpdatePoRequest(List<UpdatePoLineRequest> Items);
public record CancelPoRequest(string Reason);
public record CancelPoLineRequest(string? Reason);
public record PoItemResponse(
Guid Id,
Guid ItemId,
string Sku,
string ItemName,
int Quantity,
decimal UnitCost,
int ReceivedQuantity,
int CancelledQuantity,
bool IsCancelled);
public record PoResponse(
Guid Id,
Guid SupplierId,
string SupplierName,
Guid CreatedByUserId,
PurchaseOrderStatus Status,
string? CancelReason,
bool IsActive,
DateTime CreatedAt,
List<PoItemResponse> Items);
/// <summary>
/// Minimal Po-vs-Grn quantity/cost reconciliation. Full 3-way match (Po + Grn + Invoice) activates
/// once Finance's Invoice entity (step 19, not yet built) exists — until then this compares only
/// PurchaseOrderItem vs GoodsReceiptItem.
/// </summary>
public record InvoiceMatchLineResult(Guid PurchaseOrderItemId, Guid ItemId, int OrderedQuantity, int AcceptedQuantity, decimal UnitCost, bool QuantityMatches);
public record InvoiceMatchResult(Guid PurchaseOrderId, Guid? GoodsReceiptId, Guid? InvoiceId, bool IsMatched, List<InvoiceMatchLineResult> Lines, string Note);
public record ResolveInvoiceVarianceRequest(string Variance, string Resolution, decimal? AdjustedUnitCost, Guid? PurchaseOrderItemId);
public record InvoiceVarianceResolutionResponse(Guid PurchaseOrderId, string Variance, string Resolution, DateTime ResolvedAt);
@@ -0,0 +1,49 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Po;
public class PoRepository(AppDBContext context) : IPoRepository
{
public async Task<Models.PurchaseOrder?> GetByIdAsync(Guid id) =>
await context.PurchaseOrders
.Include(po => po.Supplier)
.Include(po => po.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(po => po.Id == id);
public async Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status)
{
var query = context.PurchaseOrders
.Include(po => po.Supplier)
.Include(po => po.Items).ThenInclude(i => i.Item)
.Where(po => po.IsActive)
.AsQueryable();
if (supplierId.HasValue) query = query.Where(po => po.FK_SupplierId == supplierId.Value);
if (status.HasValue) query = query.Where(po => po.Status == status.Value);
return await query.OrderByDescending(po => po.CreatedAt).ToListAsync();
}
public async Task AddAsync(Models.PurchaseOrder entity) =>
await context.PurchaseOrders.AddAsync(entity);
public void Update(Models.PurchaseOrder entity) =>
context.PurchaseOrders.Update(entity);
public async Task AddItemAsync(PurchaseOrderItem entity) =>
await context.PurchaseOrderItems.AddAsync(entity);
public async Task<bool> HasActiveForSupplierAsync(Guid supplierId) =>
await context.PurchaseOrders.AnyAsync(po =>
po.FK_SupplierId == supplierId &&
po.IsActive &&
po.Status != PurchaseOrderStatus.Cancelled);
public async Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId) =>
await context.PurchaseOrders
.Include(po => po.Items)
.Where(po => po.FK_SupplierId == supplierId && po.IsActive)
.ToListAsync();
}
@@ -0,0 +1,245 @@
using Distribution.Features.Grn;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Po;
public class PoService(
IPoRepository repo,
IGrnRepository goodsReceiptRepo,
IUnitOfWork uow)
{
private static PoItemResponse MapItem(PurchaseOrderItem i) =>
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.UnitCost, i.ReceivedQuantity, i.CancelledQuantity, i.IsCancelled);
private static PoResponse Map(Models.PurchaseOrder po) =>
new(po.Id, po.FK_SupplierId, po.Supplier?.Name ?? string.Empty, po.FK_CreatedByUserId,
po.Status, po.CancelReason, po.IsActive, po.CreatedAt,
po.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<List<PoResponse>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status) =>
(await repo.GetAllAsync(supplierId, status)).Select(Map).ToList();
public async Task<PoResponse?> GetByIdAsync(Guid id)
{
var po = await repo.GetByIdAsync(id);
if (po is null || !po.IsActive) return null;
return Map(po);
}
public async Task<PoResponse> CreateAsync(CreatePoRequest request, Guid createdByUserId, CancellationToken ct = default)
{
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A purchase order must have at least one line item.");
var po = new Models.PurchaseOrder
{
FK_SupplierId = request.SupplierId,
FK_CreatedByUserId = createdByUserId,
Status = PurchaseOrderStatus.Issued
};
foreach (var line in request.Items)
{
po.Items.Add(new PurchaseOrderItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
UnitCost = line.UnitCost
});
}
await uow.BeginAsync(ct);
try { await repo.AddAsync(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(po.Id))!;
}
/// <summary>
/// May only change unreceived lines (ReceivedQuantity == 0) and only while the Po is Issued.
/// Lines with a PurchaseOrderItemId update an existing line; lines without one add a new line.
/// </summary>
public async Task<PoResponse?> UpdateAsync(Guid id, UpdatePoRequest request, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id);
if (po is null || !po.IsActive) return null;
if (po.Status != PurchaseOrderStatus.Issued)
throw new InvalidOperationException($"Cannot update a purchase order in {po.Status} status; only an Issued Po can be updated.");
await uow.BeginAsync(ct);
try
{
foreach (var line in request.Items)
{
if (line.PurchaseOrderItemId.HasValue)
{
var existing = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId.Value && i.IsActive)
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
if (existing.ReceivedQuantity > 0)
throw new InvalidOperationException(
$"Line for item {existing.FK_ItemId} has already received stock and cannot be edited; use cancelPurchaseOrderLine or createPurchaseReturn instead.");
existing.FK_ItemId = line.ItemId;
existing.Quantity = line.Quantity;
existing.UnitCost = line.UnitCost;
}
else
{
await repo.AddItemAsync(new PurchaseOrderItem
{
FK_PurchaseOrderId = po.Id,
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
UnitCost = line.UnitCost
});
}
}
repo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(id))!;
}
/// <summary>
/// Only valid once a Po is Received, or manually closed early (outstanding quantity written off).
/// </summary>
public async Task<PoResponse?> CloseAsync(Guid id, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id);
if (po is null || !po.IsActive) return null;
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
throw new InvalidOperationException($"Cannot close a purchase order already in {po.Status} status.");
po.Status = PurchaseOrderStatus.Closed;
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(po);
}
/// <summary>Only valid before any goods receipt exists against the Po.</summary>
public async Task<PoResponse?> CancelAsync(Guid id, CancelPoRequest request, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id);
if (po is null || !po.IsActive) return null;
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
throw new InvalidOperationException($"Cannot cancel a purchase order already in {po.Status} status.");
if (await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(id))
throw new InvalidOperationException("Cannot cancel a purchase order that already has a goods receipt against it.");
po.Status = PurchaseOrderStatus.Cancelled;
po.CancelReason = request.Reason;
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(po);
}
/// <summary>
/// Cancels a single line instead of the whole Po. Only valid while ReceivedQuantity &lt; Quantity
/// and the line isn't already fully received or cancelled. Auto-completes the Po to Received once
/// every non-cancelled line is fully received.
/// </summary>
public async Task<PoResponse?> CancelLineAsync(Guid id, Guid poItemId, CancelPoLineRequest request, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id);
if (po is null || !po.IsActive) return null;
var line = po.Items.FirstOrDefault(i => i.Id == poItemId && i.IsActive)
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
if (line.IsCancelled)
throw new InvalidOperationException("This line is already cancelled.");
if (line.ReceivedQuantity >= line.Quantity)
throw new InvalidOperationException("This line is already fully received and cannot be cancelled.");
line.IsCancelled = true;
line.CancelledQuantity = line.Quantity - line.ReceivedQuantity;
var activeLines = po.Items.Where(i => i.IsActive).ToList();
if (activeLines.Where(i => !i.IsCancelled).All(i => i.ReceivedQuantity >= i.Quantity)
&& activeLines.Any(i => !i.IsCancelled))
{
po.Status = PurchaseOrderStatus.Received;
}
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(po);
}
/// <summary>
/// Minimal Po-vs-Grn reconciliation (matchInvoiceToPo). Full 3-way match against an Invoice
/// entity activates once Finance (step 19) exists; this only detects and reports, never adjusts.
/// </summary>
public async Task<InvoiceMatchResult?> MatchInvoiceToPoAsync(Guid poId, Guid? invoiceId, Guid? grnId)
{
var po = await repo.GetByIdAsync(poId);
if (po is null || !po.IsActive) return null;
var receipts = grnId.HasValue
? (await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId)).Where(g => g.Id == grnId.Value).ToList()
: await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId);
var acceptedByItem = receipts
.SelectMany(g => g.Items.Where(i => i.IsActive))
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
.ToDictionary(g => g.Key, g => g.Sum(i => i.AcceptedQuantity));
var lines = po.Items.Where(i => i.IsActive).Select(i =>
{
acceptedByItem.TryGetValue(i.FK_ItemId, out var accepted);
return new InvoiceMatchLineResult(i.Id, i.FK_ItemId, i.Quantity, accepted, i.UnitCost, accepted == i.Quantity);
}).ToList();
var isMatched = lines.All(l => l.QuantityMatches);
return new InvoiceMatchResult(
poId, grnId, invoiceId, isMatched, lines,
"Compares PurchaseOrderItem.Quantity against GoodsReceiptItem.AcceptedQuantity only; " +
"invoice line comparison activates once Finance's Invoice entity (step 19) exists.");
}
/// <summary>
/// Records a Po/Grn(/invoice) variance and its resolution. Never silently adjusts anything except
/// when the resolution explicitly requests a retroactive UnitCost adjustment on a named line.
/// </summary>
public async Task<InvoiceVarianceResolutionResponse?> ResolveInvoiceVarianceAsync(Guid poId, ResolveInvoiceVarianceRequest request, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(poId);
if (po is null || !po.IsActive) return null;
if (request.AdjustedUnitCost.HasValue)
{
if (!request.PurchaseOrderItemId.HasValue)
throw new InvalidOperationException("PurchaseOrderItemId is required when adjusting UnitCost.");
var line = po.Items.FirstOrDefault(i => i.Id == request.PurchaseOrderItemId.Value && i.IsActive)
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
line.UnitCost = request.AdjustedUnitCost.Value;
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
}
return new InvoiceVarianceResolutionResponse(poId, request.Variance, request.Resolution, DateTime.Now);
}
}
@@ -0,0 +1,11 @@
namespace Distribution.Features.PurchaseReturn;
public interface IPurchaseReturnRepository
{
Task<Models.PurchaseReturn?> GetByIdAsync(Guid id);
Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
Task AddAsync(Models.PurchaseReturn entity);
/// <summary>Sum of Quantity per ItemId across all prior returns for a Po.</summary>
Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId);
}
@@ -0,0 +1,33 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PurchaseReturn;
[ApiController]
[Authorize]
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/purchase-returns/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("api/purchase-orders/{id:guid}/returns")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/returns")]
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
@@ -0,0 +1,15 @@
namespace Distribution.Features.PurchaseReturn;
public record PurchaseReturnItemRequest(Guid ItemId, int Quantity);
public record CreatePurchaseReturnRequest(string Reason, List<PurchaseReturnItemRequest> Items);
public record PurchaseReturnItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, int Quantity);
public record PurchaseReturnResponse(
Guid Id,
Guid PurchaseOrderId,
string Reason,
bool IsActive,
DateTime CreatedAt,
List<PurchaseReturnItemResponse> Items);
@@ -0,0 +1,30 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.PurchaseReturn;
public class PurchaseReturnRepository(AppDBContext context) : IPurchaseReturnRepository
{
public async Task<Models.PurchaseReturn?> GetByIdAsync(Guid id) =>
await context.PurchaseReturns
.Include(pr => pr.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(pr => pr.Id == id);
public async Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.PurchaseReturns
.Include(pr => pr.Items).ThenInclude(i => i.Item)
.Where(pr => pr.FK_PurchaseOrderId == purchaseOrderId && pr.IsActive)
.OrderByDescending(pr => pr.CreatedAt)
.ToListAsync();
public async Task AddAsync(Models.PurchaseReturn entity) =>
await context.PurchaseReturns.AddAsync(entity);
public async Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId) =>
await context.PurchaseReturnItems
.Include(i => i.PurchaseReturn)
.Where(i => i.PurchaseReturn.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.FK_ItemId)
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.Quantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
}
@@ -0,0 +1,122 @@
using Distribution.Features.Grn;
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.PurchaseReturn;
public class PurchaseReturnService(
IPurchaseReturnRepository repo,
IPoRepository poRepo,
IGrnRepository goodsReceiptRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity);
private static PurchaseReturnResponse Map(Models.PurchaseReturn pr) =>
new(pr.Id, pr.FK_PurchaseOrderId, pr.Reason, pr.IsActive, pr.CreatedAt,
pr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<PurchaseReturnResponse?> GetByIdAsync(Guid id)
{
var pr = await repo.GetByIdAsync(id);
if (pr is null || !pr.IsActive) return null;
return Map(pr);
}
public async Task<List<PurchaseReturnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
/// <summary>
/// Requires at least one prior GoodsReceipt against the Po; return quantities per item cannot
/// exceed the received-but-not-already-returned quantity for that item. Decrements StockBalance
/// and writes a StockMovement in the same unit of work.
/// </summary>
public async Task<PurchaseReturnResponse> CreateAsync(Guid purchaseOrderId, CreatePurchaseReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
{
var po = await poRepo.GetByIdAsync(purchaseOrderId);
if (po is null || !po.IsActive)
throw new InvalidOperationException("Purchase order not found.");
if (!await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(purchaseOrderId))
throw new InvalidOperationException("Cannot return items for a purchase order with no goods receipt against it.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A purchase return must have at least one line item.");
var receivedTotals = await goodsReceiptRepo.GetAcceptedTotalsByItemAsync(purchaseOrderId);
var returnedTotals = await repo.GetReturnedTotalsByItemAsync(purchaseOrderId);
// The receipt(s) for this Po share a single receiving warehouse per the current model
// (each GoodsReceipt carries FK_WarehouseId); use the most recent receipt's warehouse
// as the stock-decrement location for the return.
var receipts = await goodsReceiptRepo.GetByPurchaseOrderIdAsync(purchaseOrderId);
var warehouseId = receipts.First().FK_WarehouseId;
var purchaseReturn = new Models.PurchaseReturn
{
FK_PurchaseOrderId = purchaseOrderId,
Reason = request.Reason
};
foreach (var line in request.Items)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
receivedTotals.TryGetValue(line.ItemId, out var received);
returnedTotals.TryGetValue(line.ItemId, out var alreadyReturned);
var available = received - alreadyReturned;
if (line.Quantity > available)
throw new InvalidOperationException(
$"Return quantity for item {line.ItemId} ({line.Quantity}) exceeds the received-but-not-already-returned quantity ({available}).");
purchaseReturn.Items.Add(new Models.PurchaseReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(purchaseReturn);
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.");
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,
Quantity = line.Quantity,
Type = MovementType.PurchaseReturn,
ReasonCode = request.Reason,
ReferenceId = purchaseReturn.Id,
CreatedBy = createdByUserId
});
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(purchaseReturn.Id);
return Map(saved!);
}
}
@@ -0,0 +1,14 @@
using Distribution.Models;
namespace Distribution.Features.Ride;
public interface IRideCancelRequestRepository
{
Task<RideCancelRequest?> GetByIdAsync(Guid id);
Task<RideCancelRequest?> GetByRideIdAsync(Guid rideId);
Task<RideCancelRequest?> GetPendingByRideIdAsync(Guid rideId);
Task<List<RideCancelRequest>> GetAllAsync(CancelRequestStatus? status, int page, int pageSize);
Task<int> GetCountAsync(CancelRequestStatus? status);
Task AddAsync(RideCancelRequest request);
void Update(RideCancelRequest request);
}
@@ -0,0 +1,22 @@
using Distribution.Models;
namespace Distribution.Features.Ride;
public interface IRideRepository
{
Task<DeliveryRun?> GetByIdAsync(Guid id);
Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize);
Task<int> GetCountAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId);
Task<bool> HasOpenOrActiveRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null);
Task<List<DeliveryRun>> GetActiveRidesAsync(Guid? excludeRideId = null);
Task<List<OrderSummaryModel>> GetActiveOrdersByRouteIdAsync(Guid routeId);
Task AddAsync(DeliveryRun ride);
void Update(DeliveryRun ride);
}
public record OrderSummaryModel(
Guid Id,
Guid StoreId,
string StoreName,
DateTime OrderDate,
decimal TotalAmount);
@@ -0,0 +1,39 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Ride;
public class RideCancelRequestRepository(AppDBContext context) : IRideCancelRequestRepository
{
public async Task<RideCancelRequest?> GetByIdAsync(Guid id) =>
await context.RideCancelRequests.FirstOrDefaultAsync(r => r.Id == id);
public async Task<RideCancelRequest?> GetByRideIdAsync(Guid rideId) =>
await context.RideCancelRequests
.Where(r => r.FK_RideId == rideId)
.OrderByDescending(r => r.RequestedAt)
.FirstOrDefaultAsync();
public async Task<RideCancelRequest?> GetPendingByRideIdAsync(Guid rideId) =>
await context.RideCancelRequests
.FirstOrDefaultAsync(r => r.FK_RideId == rideId && r.Status == CancelRequestStatus.Pending);
public async Task<List<RideCancelRequest>> GetAllAsync(CancelRequestStatus? status, int page, int pageSize) =>
await context.RideCancelRequests
.Where(r => status == null || r.Status == status)
.OrderByDescending(r => r.RequestedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(CancelRequestStatus? status) =>
await context.RideCancelRequests
.CountAsync(r => status == null || r.Status == status);
public async Task AddAsync(RideCancelRequest request) =>
await context.RideCancelRequests.AddAsync(request);
public void Update(RideCancelRequest request) =>
context.RideCancelRequests.Update(request);
}
@@ -0,0 +1,167 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Ride;
[ApiController]
[Route("api/rides")]
[Authorize]
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetAll(
[FromQuery] RideStatus? status,
[FromQuery] RideType? type,
[FromQuery] Guid? driverProfileId,
[FromQuery] Guid? vehicleId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "rides.create")]
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}/driver")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
{
try
{
var result = await service.AssignDriverAsync(id, request.FK_DriverProfileId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/open")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
{
try
{
var result = await service.OpenAsync(id, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/activate")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
{
try
{
var result = await service.ActivateAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/close")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
{
try
{
var result = await service.CloseAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel-request")]
[Authorize(Policy = "rides.cancel.request")]
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.RaiseCancelRequestAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("cancel-requests")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetCancelRequests(
[FromQuery] CancelRequestStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetCancelRequestsAsync(status, page, pageSize);
return Ok(result);
}
[HttpPost("cancel-requests/{id:guid}/approve")]
[Authorize(Policy = "rides.cancel.approve")]
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ApproveCancelAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("cancel-requests/{id:guid}/reject")]
[Authorize(Policy = "rides.cancel.approve")]
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.RejectCancelAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("{id:guid}/cancel-request")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetCancelRequest(Guid id)
{
var result = await service.GetCancelRequestAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("breakdown-assist/{rideId:guid}")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
{
try
{
var result = await service.GetBreakdownAssistAsync(rideId);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
}
}
+73
View File
@@ -0,0 +1,73 @@
using Distribution.Models;
using Distribution.Features.Stock;
namespace Distribution.Features.Ride;
// Ride (DeliveryRun)
public record RideResponse(
Guid Id,
RideType RideType,
RideStatus Status,
Guid VehicleId,
string VehicleNumberPlate,
Guid DriverProfileId,
string DriverFullName,
Guid RouteId,
string RouteName,
Guid SourceWarehouseId,
string SourceWarehouseName,
int Meterstart,
int Meterend,
DateTime? StartedAt,
DateTime? ActivatedAt,
DateTime? ClosedAt,
DateTime CreatedAt);
public record CreateRideRequest(
RideType RideType,
Guid FK_VehicleId,
Guid FK_DriverProfileId,
Guid FK_RouteId,
Guid? FK_SourceWarehouseId);
public record AssignRideDriverRequest(Guid FK_DriverProfileId);
public record ActivateRideRequest(int Meterstart);
public record CloseRideRequest(int Meterend);
public record RaiseCancelRequest(CancelRequestType TypeCode, string Reason);
// Cancel Request
public record RideCancelRequestResponse(
Guid Id,
Guid RideId,
CancelRequestType TypeCode,
string Reason,
Guid RequestedBy,
DateTime RequestedAt,
CancelRequestStatus Status,
Guid? ApprovedBy,
DateTime? ApprovedAt);
public record BreakdownAssistRideResponse(
Guid RideId,
Guid VehicleId,
string VehicleNumberPlate,
Guid DriverProfileId,
string DriverFullName,
Guid RouteId,
string RouteName,
List<StockBalanceResponse> StockLevels);
public record BreakdownAssistOrderResponse(
Guid OrderId,
Guid StoreId,
string StoreName,
DateTime OrderDate,
decimal TotalAmount);
public record BreakdownAssistResponse(
Guid RideId,
List<BreakdownAssistRideResponse> ActiveRides,
List<BreakdownAssistOrderResponse> OpenOrders);
@@ -0,0 +1,72 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Ride;
public class RideRepository(AppDBContext context) : IRideRepository
{
public async Task<DeliveryRun?> GetByIdAsync(Guid id) =>
await context.DeliveryRuns
.Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Include(r => r.SourceWarehouse)
.FirstOrDefaultAsync(r => r.Id == id);
public async Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) =>
await context.DeliveryRuns
.Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Include(r => r.SourceWarehouse)
.Where(r =>
(status == null || r.Status == status) &&
(type == null || r.RideType == type) &&
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
(vehicleId == null || r.FK_VehicleId == vehicleId))
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId) =>
await context.DeliveryRuns
.CountAsync(r =>
(status == null || r.Status == status) &&
(type == null || r.RideType == type) &&
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
(vehicleId == null || r.FK_VehicleId == vehicleId));
public async Task<bool> HasOpenOrActiveRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null) =>
await context.DeliveryRuns.AnyAsync(r =>
r.FK_VehicleId == vehicleId &&
r.Id != excludeRideId &&
(r.Status == RideStatus.Open || r.Status == RideStatus.Active));
public async Task<List<DeliveryRun>> GetActiveRidesAsync(Guid? excludeRideId = null) =>
await context.DeliveryRuns
.Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Where(r => r.Id != excludeRideId && r.Status == RideStatus.Active)
.ToListAsync();
public async Task<List<OrderSummaryModel>> GetActiveOrdersByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Where(o => o.IsActive && o.Store.FK_RouteId == routeId)
.OrderByDescending(o => o.OrderDate)
.Select(o => new OrderSummaryModel(
o.Id,
o.FK_StoreId,
o.Store.StoreName,
o.OrderDate,
o.TotalAmount))
.ToListAsync();
public async Task AddAsync(DeliveryRun ride) =>
await context.DeliveryRuns.AddAsync(ride);
public void Update(DeliveryRun ride) =>
context.DeliveryRuns.Update(ride);
}
+353
View File
@@ -0,0 +1,353 @@
using Distribution.Features.Stock;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Ride;
public class RideService(
IRideRepository rideRepo,
IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo,
IRideCancelRequestRepository cancelRepo,
Features.Vehicle.IVehicleRepository vehicleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
public async Task<PagedResponse<RideResponse>> GetAllAsync(
RideStatus? status,
RideType? type,
Guid? driverProfileId,
Guid? vehicleId,
int page,
int pageSize)
{
var total = await rideRepo.GetCountAsync(status, type, driverProfileId, vehicleId);
var items = await rideRepo.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize);
return new PagedResponse<RideResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<RideResponse?> GetByIdAsync(Guid id)
{
var ride = await rideRepo.GetByIdAsync(id);
return ride is null ? null : ToResponse(ride);
}
public async Task<RideResponse> CreateAsync(CreateRideRequest request, CancellationToken ct = default)
{
var vehicle = await vehicleRepo.GetByIdAsync(request.FK_VehicleId)
?? throw new InvalidOperationException("Vehicle not found");
var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.FK_SourceWarehouseId;
if (!sourceWarehouseId.HasValue)
throw new InvalidOperationException("Source warehouse is required for this vehicle");
var ride = new DeliveryRun
{
RideType = request.RideType,
FK_VehicleId = request.FK_VehicleId,
FK_DriverProfileId = request.FK_DriverProfileId,
FK_RouteId = request.FK_RouteId,
FK_SourceWarehouseId = sourceWarehouseId.Value,
Status = RideStatus.Draft
};
await uow.BeginAsync(ct);
try
{
await rideRepo.AddAsync(ride);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
var saved = await rideRepo.GetByIdAsync(ride.Id);
return ToResponse(saved!);
}
public async Task<RideResponse> OpenAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Draft)
throw new InvalidOperationException($"Cannot open a ride in {ride.Status} status");
if (await rideRepo.HasOpenOrActiveRideForVehicleAsync(ride.FK_VehicleId, ride.Id))
throw new InvalidOperationException("Vehicle already has an open or active ride");
await CheckDriverComplianceAsync(ride.FK_DriverProfileId);
ride.Status = RideStatus.Open;
ride.StartedAt = DateTime.Now;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
}
public async Task<RideResponse> AssignDriverAsync(Guid rideId, Guid driverProfileId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Draft)
throw new InvalidOperationException("Driver can only be changed while the ride is in Draft status");
var driver = await driverRepo.GetByIdAsync(driverProfileId);
if (driver is null || !driver.IsActive)
throw new InvalidOperationException("Driver profile not found or inactive");
ride.FK_DriverProfileId = driverProfileId;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
var saved = await rideRepo.GetByIdAsync(rideId);
return ToResponse(saved!);
}
public async Task<RideResponse> ActivateAsync(Guid rideId, ActivateRideRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot activate a ride in {ride.Status} status");
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
}
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
ride.Status = RideStatus.Closed;
ride.ClosedAt = DateTime.Now;
ride.Meterend = request.Meterend;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
}
public async Task<RideCancelRequestResponse> RaiseCancelRequestAsync(Guid rideId, RaiseCancelRequest request, Guid requestedBy, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException($"Cannot raise a cancel request for a ride in {ride.Status} status");
var existing = await cancelRepo.GetPendingByRideIdAsync(rideId);
if (existing is not null)
throw new InvalidOperationException("There is already a pending cancel request for this ride");
var cancelRequest = new RideCancelRequest
{
FK_RideId = rideId,
TypeCode = request.TypeCode,
Reason = request.Reason,
RequestedBy = requestedBy,
RequestedAt = DateTime.Now,
Status = CancelRequestStatus.Pending
};
ride.Status = RideStatus.CancelRequested;
await uow.BeginAsync(ct);
try
{
await cancelRepo.AddAsync(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToCancelResponse(cancelRequest);
}
public async Task<PagedResponse<RideCancelRequestResponse>> GetCancelRequestsAsync(CancelRequestStatus? status, int page, int pageSize)
{
var total = await cancelRepo.GetCountAsync(status);
var items = await cancelRepo.GetAllAsync(status, page, pageSize);
return new PagedResponse<RideCancelRequestResponse>(page, pageSize, total, items.Select(ToCancelResponse).ToList());
}
public async Task<RideCancelRequestResponse> ApproveCancelAsync(Guid cancelRequestId, Guid approvedBy, CancellationToken ct = default)
{
var cancelRequest = await cancelRepo.GetByIdAsync(cancelRequestId)
?? throw new KeyNotFoundException("Cancel request not found");
if (cancelRequest.Status != CancelRequestStatus.Pending)
throw new InvalidOperationException("Cancel request is not pending");
var ride = await rideRepo.GetByIdAsync(cancelRequest.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.CancelRequested)
throw new InvalidOperationException("Ride is not in CancelRequested status");
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
cancelRequest.Status = CancelRequestStatus.Approved;
cancelRequest.ApprovedBy = approvedBy;
cancelRequest.ApprovedAt = DateTime.Now;
ride.Status = RideStatus.Cancelled;
await uow.BeginAsync(ct);
try
{
cancelRepo.Update(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToCancelResponse(cancelRequest);
}
public async Task<RideCancelRequestResponse> RejectCancelAsync(Guid cancelRequestId, Guid rejectedBy, CancellationToken ct = default)
{
var cancelRequest = await cancelRepo.GetByIdAsync(cancelRequestId)
?? throw new KeyNotFoundException("Cancel request not found");
if (cancelRequest.Status != CancelRequestStatus.Pending)
throw new InvalidOperationException("Cancel request is not pending");
var ride = await rideRepo.GetByIdAsync(cancelRequest.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.CancelRequested)
throw new InvalidOperationException("Ride is not in CancelRequested status");
cancelRequest.Status = CancelRequestStatus.Rejected;
cancelRequest.ApprovedBy = rejectedBy;
cancelRequest.ApprovedAt = DateTime.Now;
// Restore to previous status: Active if ActivatedAt is set, else Open
ride.Status = ride.ActivatedAt.HasValue ? RideStatus.Active : RideStatus.Open;
await uow.BeginAsync(ct);
try
{
cancelRepo.Update(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToCancelResponse(cancelRequest);
}
public async Task<RideCancelRequestResponse?> GetCancelRequestAsync(Guid rideId)
{
var cancelRequest = await cancelRepo.GetByRideIdAsync(rideId);
return cancelRequest is null ? null : ToCancelResponse(cancelRequest);
}
public async Task<BreakdownAssistResponse> GetBreakdownAssistAsync(Guid rideId)
{
var strandedRide = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
var activeRides = await rideRepo.GetActiveRidesAsync(rideId);
var activeRideResponses = new List<BreakdownAssistRideResponse>();
foreach (var ride in activeRides)
{
var stockLevels = await stockRepo.GetBalancesByLocationAsync(ride.FK_VehicleId, LocationType.Vehicle);
activeRideResponses.Add(new BreakdownAssistRideResponse(
ride.Id,
ride.FK_VehicleId,
ride.Vehicle?.NumberPlate ?? string.Empty,
ride.FK_DriverProfileId,
ride.DriverProfile?.User?.FullName ?? string.Empty,
ride.FK_RouteId,
ride.Route?.RouteName ?? string.Empty,
stockLevels.Select(b => new StockBalanceResponse(
b.FK_ItemId,
b.Item.Name,
b.LocationId,
b.LocationType,
b.Quantity,
b.UpdatedAt)).ToList()));
}
var openOrders = await rideRepo.GetActiveOrdersByRouteIdAsync(strandedRide.FK_RouteId);
return new BreakdownAssistResponse(
strandedRide.Id,
activeRideResponses,
openOrders.Select(o => new BreakdownAssistOrderResponse(
o.Id,
o.StoreId,
o.StoreName,
o.OrderDate,
o.TotalAmount)).ToList());
}
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
{
var driver = await driverRepo.GetByIdAsync(driverProfileId)
?? throw new InvalidOperationException("Driver profile not found");
if (!driver.IsActive)
throw new InvalidOperationException("Driver profile is inactive");
var config = await complianceRepo.GetAsync();
if (config is null) return;
var today = DateTime.Now.Date;
if (config.EnforceLicenseExpiry && driver.LicenseExpiry.Date <= today)
throw new InvalidOperationException("Driver license has expired");
if (config.EnforceMedicalClearance && driver.MedicalClearanceExpiry.Date <= today)
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.RideType, r.Status,
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
r.FK_DriverProfileId, r.DriverProfile?.User?.FullName ?? string.Empty,
r.FK_RouteId, r.Route?.RouteName ?? string.Empty,
r.FK_SourceWarehouseId, r.SourceWarehouse?.Name ?? string.Empty,
r.Meterstart, r.Meterend,
r.StartedAt, r.ActivatedAt, r.ClosedAt, r.CreatedAt);
private static RideCancelRequestResponse ToCancelResponse(RideCancelRequest c) =>
new(c.Id, c.FK_RideId, c.TypeCode, c.Reason, c.RequestedBy, c.RequestedAt,
c.Status, c.ApprovedBy, c.ApprovedAt);
}
@@ -0,0 +1,14 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public interface ISaleRepository
{
Task<Models.Sale?> GetByIdAsync(Guid id);
Task<List<Models.Sale>> GetAllAsync();
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.Sale sale);
void Update(Models.Sale sale);
void Remove(Models.Sale sale);
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
[Authorize]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? Forbid() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
}
+33
View File
@@ -0,0 +1,33 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public record SaleItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateSaleRequest(
Guid StoreId,
DateTime SaleDate,
decimal TotalAmount,
bool FulfillImmediately,
Guid? RideId,
List<SaleItemRequest> Items);
public record SaleItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record SaleResponse(
Guid Id,
DateTime SaleDate,
Guid StoreId,
Guid CreatedByUserId,
Guid? RideId,
decimal TotalAmount,
SaleStatus Status,
PaymentStatus PaymentStatus,
bool FulfillImmediately,
DateTime? CompletedAt,
Guid OrderId,
OrderStatus OrderStatus,
DateTime? OrderFulfilledAt,
List<SaleItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,32 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Sale;
public class SaleRepository(AppDBContext context) : ISaleRepository
{
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
await context.Sales.FindAsync(id);
public async Task<List<Models.Sale>> GetAllAsync() =>
await context.Sales.ToListAsync();
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
public async Task AddAsync(Models.Sale sale) =>
await context.Sales.AddAsync(sale);
public void Update(Models.Sale sale) =>
context.Sales.Update(sale);
public void Remove(Models.Sale sale)
{
sale.IsActive = false;
context.Sales.Update(sale);
}
public async Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
}
+233
View File
@@ -0,0 +1,233 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Sale;
public class SaleService(
ISaleRepository repo,
IOrderRepository orderRepo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
PriceListService priceListService,
IStockRepository stockRepo,
IRideRepository rideRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
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 async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime saleDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, saleDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
private static SaleResponse ToResponse(Models.Sale sale, Models.Order order, List<Models.OrderItem> items) =>
new(
sale.Id,
sale.SaleDate,
sale.FK_StoreId,
sale.CreatedByUserId,
sale.FK_RideId,
sale.TotalAmount,
sale.Status,
sale.PaymentStatus,
sale.FulfillImmediately,
sale.CompletedAt,
order.Id,
order.Status,
order.FulfilledAt,
items.Select(i => new SaleItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
sale.IsActive,
sale.CreatedAt);
public async Task<List<SaleResponse>> GetAllAsync()
{
var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
if (!HasGlobalScope && currentUser.UserId is not null)
{
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList();
}
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> GetByIdAsync(Guid id)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<List<SaleResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var sales = await repo.GetByStoreIdAsync(storeId);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var saleStatus = request.FulfillImmediately ? SaleStatus.Completed : SaleStatus.Pending;
var orderStatus = request.FulfillImmediately ? OrderStatus.Fulfilled : OrderStatus.Pending;
var fulfilledAt = request.FulfillImmediately ? DateTime.Now : (DateTime?)null;
var sale = new Models.Sale
{
FK_StoreId = request.StoreId,
SaleDate = request.SaleDate,
CreatedByUserId = userId,
FK_RideId = request.RideId,
TotalAmount = request.TotalAmount,
Status = saleStatus,
FulfillImmediately = request.FulfillImmediately,
CompletedAt = request.FulfillImmediately ? DateTime.Now : null
};
var orderItems = new List<Models.OrderItem>();
foreach (var item in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_ItemId = item.ItemId,
Quantity = item.Quantity,
QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0,
UnitPrice = unitPrice.Value
});
}
var order = new Models.Order
{
FK_SaleId = sale.Id,
FK_StoreId = request.StoreId,
OrderDate = request.SaleDate,
TotalAmount = request.TotalAmount,
Status = orderStatus,
FulfilledAt = fulfilledAt
};
foreach (var orderItem in orderItems)
orderItem.FK_OrderId = order.Id;
Guid? vehicleId = null;
if (request.FulfillImmediately && request.RideId.HasValue)
{
var ride = await rideRepo.GetByIdAsync(request.RideId.Value);
if (ride is null) return null;
vehicleId = ride.FK_VehicleId;
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
if (balance is null || balance.Quantity < item.Quantity)
return null;
}
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sale);
await orderRepo.AddAsync(order);
await orderRepo.AddItemsAsync(orderItems);
if (vehicleId.HasValue)
{
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
balance!.Quantity -= item.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId.Value,
FromLocationType = LocationType.Vehicle,
Quantity = item.Quantity,
Type = MovementType.Sale,
ReferenceId = order.Id
});
}
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(sale, order, orderItems);
}
}
@@ -0,0 +1,19 @@
namespace Distribution.Features.Scheme;
public interface ISchemeRepository
{
Task<Models.Scheme?> GetByIdAsync(Guid id);
Task<List<Models.Scheme>> GetAllAsync(Guid? regionId = null);
Task AddAsync(Models.Scheme entity);
void Update(Models.Scheme entity);
void Remove(Models.Scheme entity);
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
Task AddRedemptionAsync(Models.SchemeRedemption entity);
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
void UpdateBundleComponent(Models.SchemeBundleComponent entity);
void RemoveBundleComponent(Models.SchemeBundleComponent entity);
}
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Scheme;
[ApiController]
[Route("api/schemes")]
[Authorize]
public class SchemeController(SchemeService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateSchemeRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateSchemeRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/free-goods-rule")]
public async Task<IActionResult> SetFreeGoodsRule(Guid id, SetFreeGoodsRuleRequest request, CancellationToken ct)
{
try
{
var result = await service.SetFreeGoodsRuleAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}/free-goods-rule")]
public async Task<IActionResult> ClearFreeGoodsRule(Guid id, CancellationToken ct)
{
var result = await service.ClearFreeGoodsRuleAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/bundle-components/{itemId:guid}")]
public async Task<IActionResult> UpsertBundleComponent(Guid id, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct)
{
try
{
var result = await service.UpsertBundleComponentAsync(id, itemId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}/bundle-components/{itemId:guid}")]
public async Task<IActionResult> RemoveBundleComponent(Guid id, Guid itemId, CancellationToken ct)
{
var result = await service.RemoveBundleComponentAsync(id, itemId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("eligible")]
public async Task<IActionResult> GetEligible([FromQuery] Guid storeId, [FromQuery] Guid orderId)
{
var result = await service.EvaluateEligibilityAsync(storeId, orderId);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/spend")]
public async Task<IActionResult> GetSpend(Guid id)
{
var result = await service.GetSpendAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/roi")]
public async Task<IActionResult> GetROI(Guid id)
{
var result = await service.GetROIAsync(id);
return result is null ? NotFound() : Ok(result);
}
}
[ApiController]
[Route("api/orders")]
[Authorize]
public class SchemeOrderController(SchemeService service) : ControllerBase
{
[HttpPost("{id:guid}/apply-scheme")]
public async Task<IActionResult> ApplyScheme(Guid id, ApplySchemeRequest request, CancellationToken ct)
{
try
{
var result = await service.ApplySchemeAsync(id, request.SchemeId, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using Distribution.Models;
namespace Distribution.Features.Scheme;
// ── Rule config payloads for percentage-based rules (serialized into Scheme.RuleConfig) ──
public record SlabDiscountConfig(List<SlabTier> Tiers);
public record SlabTier(decimal MinOrderAmount, decimal DiscountPercent);
public record FlatPercentConfig(decimal DiscountPercent);
// ── Requests ────────────────────────────────────────────────────────────
public record BundleComponentRequest(Guid ItemId, int Qty);
/// <summary>
/// TriggerItemId/TriggerQty/FreeItemId/FreeQty/BundleComponents are optional so plain discount
/// schemes (SlabDiscount/FlatPercent) can omit them entirely. When RuleType is BuyXGetY, supplying
/// TriggerItemId+TriggerQty+FreeItemId+FreeQty here creates the scheme fully populated in one call
/// instead of a separate PUT .../free-goods-rule. When RuleType is FreeBundle, supplying
/// FreeItemId+FreeQty+BundleComponents does the same. These can still be added/edited later via
/// PUT .../free-goods-rule and PUT .../bundle-components/{itemId}.
/// </summary>
public record CreateSchemeRequest(
string Name,
SchemeRuleType RuleType,
string? RuleConfig,
Guid? RegionId,
string? EligibleSegment,
DateTime DateRangeFrom,
DateTime DateRangeTo,
bool AgentLimitExempt,
decimal? BaselineExpectedSales,
Guid? TriggerItemId = null,
int? TriggerQty = null,
Guid? FreeItemId = null,
decimal? FreeQty = null,
List<BundleComponentRequest>? BundleComponents = null);
public record UpdateSchemeRequest(
string Name,
string? RuleConfig,
Guid? RegionId,
string? EligibleSegment,
DateTime DateRangeFrom,
DateTime DateRangeTo,
bool AgentLimitExempt,
decimal? BaselineExpectedSales);
public record ApplySchemeRequest(Guid SchemeId);
/// <summary>
/// Sets the free-goods payout for a scheme. For BuyXGetY, TriggerItemId/TriggerQty are required
/// (what the store must buy); for FreeBundle they are ignored — the required items instead come
/// from the scheme's bundle-components CRUD. FreeItemId/FreeQty is the payout in both cases.
/// </summary>
public record SetFreeGoodsRuleRequest(Guid? TriggerItemId, int? TriggerQty, Guid FreeItemId, decimal FreeQty);
public record UpsertBundleComponentRequest(int Qty);
// ── Responses ───────────────────────────────────────────────────────────
public record BundleComponentResponse(Guid ItemId, string Sku, string ItemName, int Qty);
public record SchemeResponse(
Guid Id,
string Name,
SchemeRuleType RuleType,
string? RuleConfig,
Guid? TriggerItemId,
string? TriggerItemName,
int? TriggerQty,
Guid? FreeItemId,
string? FreeItemName,
decimal? FreeQty,
List<BundleComponentResponse> BundleComponents,
Guid? RegionId,
string? RegionName,
string? EligibleSegment,
DateTime DateRangeFrom,
DateTime DateRangeTo,
bool AgentLimitExempt,
decimal? BaselineExpectedSales,
bool IsActive,
DateTime CreatedAt);
public record SchemeEligibilityResponse(
Guid SchemeId,
string SchemeName,
SchemeRuleType RuleType,
bool Eligible,
string? Reason,
decimal? DiscountAmount,
Guid? FreeItemId,
decimal? FreeQty);
public record SchemeRedemptionResponse(
Guid Id,
Guid SchemeId,
Guid? OrderId,
decimal? DiscountAmount,
Guid? FreeItemId,
string? FreeItemName,
decimal? FreeQty,
decimal? FreeItemCostValue,
DateTime RedeemedAt);
public record SchemeSpendResponse(Guid SchemeId, decimal TotalDiscountSpend, decimal TotalFreeGoodsCostSpend, decimal TotalSpend, int RedemptionCount);
public record SchemeROIResponse(Guid SchemeId, decimal BaselineExpectedSales, decimal ActualSales, decimal IncrementalSales, decimal TotalSpend, decimal? ROI);
@@ -0,0 +1,67 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Scheme;
public class SchemeRepository(AppDBContext context) : ISchemeRepository
{
public async Task<Models.Scheme?> GetByIdAsync(Guid id) =>
await context.Schemes
.Include(s => s.Region)
.Include(s => s.TriggerItem)
.Include(s => s.FreeItem)
.Include(s => s.BundleComponents).ThenInclude(c => c.Item)
.FirstOrDefaultAsync(s => s.Id == id);
public async Task<List<Models.Scheme>> GetAllAsync(Guid? regionId = null)
{
var query = context.Schemes
.Include(s => s.Region)
.Include(s => s.TriggerItem)
.Include(s => s.FreeItem)
.Include(s => s.BundleComponents).ThenInclude(c => c.Item)
.Where(s => s.IsActive);
if (regionId.HasValue) query = query.Where(s => s.FK_RegionId == regionId);
return await query.ToListAsync();
}
public async Task AddAsync(Models.Scheme entity) =>
await context.Schemes.AddAsync(entity);
public void Update(Models.Scheme entity) =>
context.Schemes.Update(entity);
public void Remove(Models.Scheme entity)
{
entity.IsActive = false;
context.Schemes.Update(entity);
}
public async Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId) =>
await context.SchemeRedemptions
.Include(r => r.FreeItem)
.Include(r => r.Order)
.Where(r => r.FK_SchemeId == schemeId)
.ToListAsync();
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
await context.SchemeRedemptions.AddAsync(entity);
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId);
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
await context.SchemeBundleComponents
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
public async Task AddBundleComponentAsync(Models.SchemeBundleComponent entity) =>
await context.SchemeBundleComponents.AddAsync(entity);
public void UpdateBundleComponent(Models.SchemeBundleComponent entity) =>
context.SchemeBundleComponents.Update(entity);
public void RemoveBundleComponent(Models.SchemeBundleComponent entity) =>
context.SchemeBundleComponents.Remove(entity);
}
@@ -0,0 +1,515 @@
using System.Text.Json;
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Scheme;
public class SchemeService(
ISchemeRepository repo,
IOrderRepository orderRepo,
IItemRepository itemRepo,
IStoreRepository storeRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static SchemeResponse ToResponse(Models.Scheme s) =>
new(s.Id, s.Name, s.RuleType, s.RuleConfig,
s.FK_TriggerItemId, s.TriggerItem?.Name, s.TriggerQty,
s.FK_FreeItemId, s.FreeItem?.Name, s.FreeQty,
s.BundleComponents.Select(c => new BundleComponentResponse(c.FK_ItemId, c.Item.Sku, c.Item.Name, c.Qty)).ToList(),
s.FK_RegionId, s.Region?.Name, s.EligibleSegment,
s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt);
private static SchemeRedemptionResponse ToRedemptionResponse(SchemeRedemption r) =>
new(r.Id, r.FK_SchemeId, r.FK_OrderId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
r.FreeQty, r.FreeItemCostValue, r.RedeemedAt);
// ── CRUD ───────────────────────────────────────────────────────────────
public async Task<List<SchemeResponse>> GetAllAsync(Guid? regionId = null)
{
var schemes = await repo.GetAllAsync(regionId);
return schemes.Select(ToResponse).ToList();
}
public async Task<SchemeResponse?> GetByIdAsync(Guid id)
{
var scheme = await repo.GetByIdAsync(id);
return scheme is null || !scheme.IsActive ? null : ToResponse(scheme);
}
/// <summary>
/// createScheme(schemeRules, eligibility, dateRange). RuleConfig is only meaningful for
/// SlabDiscount/FlatPercent. For BuyXGetY/FreeBundle, the free-goods payout (and bundle
/// components) can be supplied inline here to create a fully-populated scheme in one call, or
/// added afterwards via SetFreeGoodsRuleAsync / UpsertBundleComponentAsync.
/// </summary>
public async Task<SchemeResponse> CreateAsync(CreateSchemeRequest request, CancellationToken ct = default)
{
ValidatePercentRuleConfig(request.RuleType, request.RuleConfig);
if (request.DateRangeFrom > request.DateRangeTo)
throw new InvalidOperationException("DateRangeFrom must not be after DateRangeTo.");
var hasFreeGoodsRule = request.FreeItemId.HasValue || request.FreeQty.HasValue ||
request.TriggerItemId.HasValue || request.TriggerQty.HasValue;
if (hasFreeGoodsRule)
await ValidateFreeGoodsRuleAsync(request.RuleType, request.TriggerItemId, request.TriggerQty, request.FreeItemId, request.FreeQty);
if (request.BundleComponents is { Count: > 0 })
{
if (request.RuleType != SchemeRuleType.FreeBundle)
throw new InvalidOperationException("BundleComponents only applies to FreeBundle schemes.");
foreach (var c in request.BundleComponents)
{
if (c.Qty <= 0)
throw new InvalidOperationException("Bundle component Qty must be positive.");
if (await itemRepo.GetByIdAsync(c.ItemId) is null)
throw new InvalidOperationException($"Bundle component item '{c.ItemId}' not found.");
}
}
var scheme = new Models.Scheme
{
Name = request.Name,
RuleType = request.RuleType,
RuleConfig = request.RuleConfig,
FK_RegionId = request.RegionId,
EligibleSegment = request.EligibleSegment,
DateRangeFrom = request.DateRangeFrom,
DateRangeTo = request.DateRangeTo,
AgentLimitExempt = request.AgentLimitExempt,
BaselineExpectedSales = request.BaselineExpectedSales
};
if (hasFreeGoodsRule)
{
scheme.FK_TriggerItemId = request.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerItemId : null;
scheme.TriggerQty = request.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerQty : null;
scheme.FK_FreeItemId = request.FreeItemId;
scheme.FreeQty = request.FreeQty;
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(scheme);
if (request.BundleComponents is { Count: > 0 })
{
foreach (var c in request.BundleComponents)
await repo.AddBundleComponentAsync(new SchemeBundleComponent { FK_SchemeId = scheme.Id, FK_ItemId = c.ItemId, Qty = c.Qty });
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(scheme.Id))!;
}
public async Task<SchemeResponse?> UpdateAsync(Guid id, UpdateSchemeRequest request, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(id);
if (scheme is null || !scheme.IsActive) return null;
ValidatePercentRuleConfig(scheme.RuleType, request.RuleConfig);
if (request.DateRangeFrom > request.DateRangeTo)
throw new InvalidOperationException("DateRangeFrom must not be after DateRangeTo.");
scheme.Name = request.Name;
scheme.RuleConfig = request.RuleConfig;
scheme.FK_RegionId = request.RegionId;
scheme.EligibleSegment = request.EligibleSegment;
scheme.DateRangeFrom = request.DateRangeFrom;
scheme.DateRangeTo = request.DateRangeTo;
scheme.AgentLimitExempt = request.AgentLimitExempt;
scheme.BaselineExpectedSales = request.BaselineExpectedSales;
await uow.BeginAsync(ct);
try { repo.Update(scheme); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(scheme);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(id);
if (scheme is null || !scheme.IsActive) return false;
await uow.BeginAsync(ct);
try { repo.Remove(scheme); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return true;
}
// ── Free-goods rule CRUD (BuyXGetY trigger + shared payout) ──────────────
public async Task<SchemeResponse?> SetFreeGoodsRuleAsync(Guid schemeId, SetFreeGoodsRuleRequest request, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
await ValidateFreeGoodsRuleAsync(scheme.RuleType, request.TriggerItemId, request.TriggerQty, request.FreeItemId, request.FreeQty);
scheme.FK_TriggerItemId = scheme.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerItemId : null;
scheme.TriggerQty = scheme.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerQty : null;
scheme.FK_FreeItemId = request.FreeItemId;
scheme.FreeQty = request.FreeQty;
await uow.BeginAsync(ct);
try { repo.Update(scheme); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(schemeId))!;
}
public async Task<SchemeResponse?> ClearFreeGoodsRuleAsync(Guid schemeId, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
scheme.FK_TriggerItemId = null;
scheme.TriggerQty = null;
scheme.FK_FreeItemId = null;
scheme.FreeQty = null;
await uow.BeginAsync(ct);
try { repo.Update(scheme); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(schemeId))!;
}
// ── Bundle component CRUD (FreeBundle required items) ────────────────────
public async Task<SchemeResponse?> UpsertBundleComponentAsync(Guid schemeId, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
if (scheme.RuleType != SchemeRuleType.FreeBundle)
throw new InvalidOperationException("Bundle components only apply to FreeBundle schemes.");
if (request.Qty <= 0)
throw new InvalidOperationException("Qty must be positive.");
var item = await itemRepo.GetByIdAsync(itemId)
?? throw new InvalidOperationException("Item not found.");
var existing = await repo.GetBundleComponentAsync(schemeId, itemId);
await uow.BeginAsync(ct);
try
{
if (existing is null)
{
await repo.AddBundleComponentAsync(new SchemeBundleComponent
{
FK_SchemeId = schemeId,
FK_ItemId = item.Id,
Qty = request.Qty
});
}
else
{
existing.Qty = request.Qty;
repo.UpdateBundleComponent(existing);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(schemeId))!;
}
public async Task<SchemeResponse?> RemoveBundleComponentAsync(Guid schemeId, Guid itemId, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
var existing = await repo.GetBundleComponentAsync(schemeId, itemId);
if (existing is null) return null;
await uow.BeginAsync(ct);
try { repo.RemoveBundleComponent(existing); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(schemeId))!;
}
// ── evaluateSchemeEligibility(storeId, orderId) ──────────────────────────
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid orderId)
{
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
if (store is null || !store.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var regionId = store.Route?.FK_RegionId;
var candidates = await repo.GetAllAsync();
var result = new List<SchemeEligibilityResponse>();
foreach (var scheme in candidates)
{
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
continue;
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
continue;
if (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo)
continue;
result.Add(EvaluateRule(scheme, orderItems));
}
return result;
}
/// <summary>
/// Scope-only check (region, segment, date range as of today) with no order to evaluate line
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
/// (12_store_management.md), which has no order context. Returns null if the store or scheme
/// doesn't exist; otherwise (Eligible, Reason) — Eligible here means "in scope for this store",
/// not "this order's items satisfy the rule" (use EvaluateEligibilityAsync for that).
/// </summary>
public async Task<(bool Eligible, string? Reason)?> CheckStoreScopeEligibilityAsync(Guid storeId, Guid schemeId)
{
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
if (store is null || !store.IsActive) return null;
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
var regionId = store.Route?.FK_RegionId;
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
return (false, "Scheme is not available in the store's region.");
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
var today = DateTime.Now;
if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo)
return (false, "Scheme is not currently active (outside its date range).");
return (true, null);
}
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<OrderItem> orderItems)
{
switch (scheme.RuleType)
{
case SchemeRuleType.BuyXGetY:
{
if (scheme.FK_TriggerItemId is null || scheme.TriggerQty is null or <= 0 ||
scheme.FK_FreeItemId is null || scheme.FreeQty is null or <= 0)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Scheme has no free-goods rule configured.", null, null, null);
var boughtQty = orderItems.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity);
if (boughtQty < scheme.TriggerQty)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
$"Requires {scheme.TriggerQty} of the trigger item; order has {boughtQty}.", null, null, null);
var multiplier = boughtQty / scheme.TriggerQty.Value;
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
null, scheme.FK_FreeItemId, scheme.FreeQty * multiplier);
}
case SchemeRuleType.FreeBundle:
{
if (scheme.BundleComponents.Count == 0 || scheme.FK_FreeItemId is null || scheme.FreeQty is null or <= 0)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Scheme has no bundle components or free-goods payout configured.", null, null, null);
var missing = scheme.BundleComponents
.Where(c => orderItems.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty)
.ToList();
if (missing.Count > 0)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Order is missing required bundle components.", null, null, null);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
null, scheme.FK_FreeItemId, scheme.FreeQty);
}
case SchemeRuleType.FlatPercent:
{
if (string.IsNullOrEmpty(scheme.RuleConfig))
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
var cfg = JsonSerializer.Deserialize<FlatPercentConfig>(scheme.RuleConfig, JsonOptions)!;
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null);
}
case SchemeRuleType.SlabDiscount:
{
if (string.IsNullOrEmpty(scheme.RuleConfig))
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
var cfg = JsonSerializer.Deserialize<SlabDiscountConfig>(scheme.RuleConfig, JsonOptions)!;
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
var tier = cfg.Tiers.Where(t => orderTotal >= t.MinOrderAmount)
.OrderByDescending(t => t.MinOrderAmount).FirstOrDefault();
if (tier is null)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Order total does not meet any slab threshold.", null, null, null);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
Math.Round(orderTotal * tier.DiscountPercent / 100m, 2), null, null);
}
default:
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Unknown rule type.", null, null, null);
}
}
// ── applySchemeDiscount(orderId, schemeId) ───────────────────────────────
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid orderId, Guid schemeId, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
if (await repo.HasRedemptionAsync(schemeId, orderId))
throw new InvalidOperationException("This scheme has already been redeemed against this order.");
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var eligibility = EvaluateRule(scheme, orderItems);
if (!eligibility.Eligible)
throw new InvalidOperationException(eligibility.Reason ?? "Order is not eligible for this scheme.");
var redemption = new SchemeRedemption
{
FK_SchemeId = schemeId,
FK_OrderId = orderId,
DiscountAmount = eligibility.DiscountAmount
};
var newOrderItems = new List<OrderItem>();
if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0)
{
var freeItem = await itemRepo.GetByIdAsync(eligibility.FreeItemId.Value)
?? throw new InvalidOperationException("Free item not found.");
var availableQty = (await stockRepo.GetBalancesByItemAsync(freeItem.Id)).Sum(b => b.Quantity);
if (availableQty < eligibility.FreeQty.Value)
throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme.");
newOrderItems.Add(new OrderItem
{
FK_OrderId = orderId,
FK_ItemId = freeItem.Id,
Quantity = (int)eligibility.FreeQty.Value,
UnitPrice = 0m
});
redemption.FK_FreeItemId = freeItem.Id;
redemption.FreeQty = eligibility.FreeQty;
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m;
}
await uow.BeginAsync(ct);
try
{
if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems);
await repo.AddRedemptionAsync(redemption);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var reloaded = (await repo.GetRedemptionsAsync(schemeId)).First(r => r.Id == redemption.Id);
return ToRedemptionResponse(reloaded);
}
// ── trackSchemeSpend(schemeId) ───────────────────────────────────────────
public async Task<SchemeSpendResponse?> GetSpendAsync(Guid schemeId)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null) return null;
var redemptions = await repo.GetRedemptionsAsync(schemeId);
var discountSpend = redemptions.Sum(r => r.DiscountAmount ?? 0m);
var freeGoodsSpend = redemptions.Sum(r => (r.FreeItemCostValue ?? 0m) * (r.FreeQty ?? 0m));
return new SchemeSpendResponse(schemeId, discountSpend, freeGoodsSpend, discountSpend + freeGoodsSpend, redemptions.Count);
}
// ── getSchemeROI(schemeId) ───────────────────────────────────────────────
public async Task<SchemeROIResponse?> GetROIAsync(Guid schemeId)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null) return null;
var redemptions = await repo.GetRedemptionsAsync(schemeId);
var spend = await GetSpendAsync(schemeId);
var actualSales = redemptions
.Where(r => r.Order is not null)
.Select(r => r.Order!)
.Distinct()
.Sum(o => o.TotalAmount);
var baseline = scheme.BaselineExpectedSales ?? 0m;
var incremental = actualSales - baseline;
decimal? roi = spend!.TotalSpend > 0 ? incremental / spend.TotalSpend : null;
return new SchemeROIResponse(schemeId, baseline, actualSales, incremental, spend.TotalSpend, roi);
}
private async Task ValidateFreeGoodsRuleAsync(SchemeRuleType ruleType, Guid? triggerItemId, int? triggerQty, Guid? freeItemId, decimal? freeQty)
{
if (ruleType is not (SchemeRuleType.BuyXGetY or SchemeRuleType.FreeBundle))
throw new InvalidOperationException($"Free-goods rule only applies to BuyXGetY or FreeBundle schemes, not '{ruleType}'.");
if (ruleType == SchemeRuleType.BuyXGetY)
{
if (triggerItemId is null || triggerQty is null or <= 0)
throw new InvalidOperationException("BuyXGetY requires TriggerItemId and a positive TriggerQty.");
if (await itemRepo.GetByIdAsync(triggerItemId.Value) is null)
throw new InvalidOperationException("Trigger item not found.");
}
if (freeItemId is null || freeQty is null or <= 0)
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
throw new InvalidOperationException("Free item not found.");
}
private static void ValidatePercentRuleConfig(SchemeRuleType ruleType, string? ruleConfig)
{
if (ruleType is not (SchemeRuleType.SlabDiscount or SchemeRuleType.FlatPercent)) return;
if (string.IsNullOrWhiteSpace(ruleConfig))
throw new InvalidOperationException($"RuleConfig is required for RuleType '{ruleType}'.");
try
{
if (ruleType == SchemeRuleType.SlabDiscount)
{
var slab = JsonSerializer.Deserialize<SlabDiscountConfig>(ruleConfig, JsonOptions) ?? throw new JsonException();
if (slab.Tiers.Count == 0)
throw new InvalidOperationException("SlabDiscount scheme requires at least one tier.");
}
else
{
_ = JsonSerializer.Deserialize<FlatPercentConfig>(ruleConfig, JsonOptions) ?? throw new JsonException();
}
}
catch (JsonException)
{
throw new InvalidOperationException($"RuleConfig is not valid JSON for RuleType '{ruleType}'.");
}
}
}
@@ -6,6 +6,7 @@ public interface IStockRepository
{
// Balance
Task<StockBalance?> GetBalanceAsync(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);
@@ -11,6 +11,12 @@ public class StockRepository(AppDBContext db) : IStockRepository
.Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId);
public Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
db.StockBalances
.Include(b => b.Item)
.Where(b => b.LocationId == locationId && b.LocationType == locationType)
.ToListAsync();
public Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize)
{
var query = db.StockBalances.Include(b => b.Item).AsQueryable();
@@ -3,8 +3,10 @@ namespace Distribution.Features.Store;
public interface IStoreRepository
{
Task<Models.Store?> GetByIdAsync(Guid id);
Task<Models.Store?> GetByIdWithRegionAsync(Guid id);
Task<List<Models.Store>> GetAllAsync();
Task AddAsync(Models.Store entity);
void Update(Models.Store entity);
void Remove(Models.Store entity);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
}
@@ -0,0 +1,7 @@
namespace Distribution.Features.Store.Kyc;
public interface IStoreKycDocumentRepository
{
Task<List<Models.StoreKycDocument>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.StoreKycDocument entity);
}

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