Compare commits

...

20 Commits

Author SHA1 Message Date
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
125 changed files with 11850 additions and 7486 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=
+180
View File
@@ -30,6 +30,23 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
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<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)
{
@@ -58,6 +75,169 @@ 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<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<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);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
}
}
@@ -14,65 +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"),
("stores.kyc.verify", "Verify or reject store KYC documents"),
("stores.credit.update", "Update store credit limit and outstanding balance"),
("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(
@@ -83,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", "stores.credit.update"]).ToArray();
var areaManagerPerms = supervisorPerms.Concat(["vehicles.view", "warehouses.view", "stores.kyc.verify"]).Concat(itemManagePerms).Distinct().ToArray();
var areaSupervisorPerms = areaManagerPerms.Concat(["routes.create", "routes.update", "stores.delete"]).Concat(itemPerms).Distinct().ToArray();
var regionalManagerPerms = new[]
{
"orders.view", "orders.create", "orders.update", "orders.delete",
"stores.view", "stores.create", "stores.update", "stores.delete", "stores.kyc.verify", "stores.credit.update",
"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,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,60 @@
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)
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)oi.Quantity) })
.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());
}
+2 -2
View File
@@ -1,12 +1,12 @@
namespace Distribution.Features.Order;
public record OrderItemRequest(Guid ItemId, int Quantity);
public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
public record OrderItemResponse(Guid ItemId, int Quantity);
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
public record OrderResponse(
Guid Id,
+59 -7
View File
@@ -1,4 +1,7 @@
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;
@@ -9,6 +12,9 @@ namespace Distribution.Features.Order;
public class OrderService(
IOrderRepository repo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
@@ -18,7 +24,7 @@ public class OrderService(
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(),
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
@@ -29,6 +35,42 @@ public class OrderService(
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
/// <summary>
/// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the
/// region-scoped active price list, then global, then Item.SalePrice, and validates any
/// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price
/// can be resolved, or if the requested price is outside the allowed band.
/// </summary>
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate)
{
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, orderDate);
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;
}
/// <summary>
/// checkStockAvailability (mainPlan.md §1): confirms the requested quantity does not exceed
/// total on-hand stock for the item, summed across every warehouse/vehicle location. Does not
/// reserve or decrement stock — actual decrement happens later at loading/delivery time
/// (04_loading_event.md / future 05-06 ride execution), not at order placement.
/// </summary>
private async Task<bool> HasSufficientStockAsync(Guid itemId, decimal quantity)
{
var balances = await stockRepo.GetBalancesByItemAsync(itemId);
var totalAvailable = balances.Sum(b => b.Quantity);
return quantity <= totalAvailable;
}
public async Task<List<OrderResponse>> GetAllAsync()
{
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
@@ -72,7 +114,7 @@ public class OrderService(
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var store = await storeRepo.GetByIdAsync(request.StoreId);
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
@@ -86,12 +128,22 @@ public class OrderService(
OrderDate = request.OrderDate,
TotalAmount = request.TotalAmount
};
var orderItems = request.Items.Select(i => new Models.OrderItem
var orderItems = new List<Models.OrderItem>();
foreach (var i in request.Items)
{
FK_OrderId = order.Id,
FK_ItemId = i.ItemId,
Quantity = i.Quantity
}).ToList();
if (!await HasSufficientStockAsync(i.ItemId, i.Quantity)) return null;
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_OrderId = order.Id,
FK_ItemId = i.ItemId,
Quantity = i.Quantity,
UnitPrice = unitPrice.Value
});
}
await uow.BeginAsync(ct);
try
@@ -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,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,6 +3,7 @@ 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);
@@ -8,6 +8,11 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
public async Task<Models.Store?> GetByIdAsync(Guid id) =>
await context.Stores.FindAsync(id);
public async Task<Models.Store?> GetByIdWithRegionAsync(Guid id) =>
await context.Stores
.Include(s => s.Route).ThenInclude(r => r.Region)
.FirstOrDefaultAsync(s => s.Id == id);
public async Task<List<Models.Store>> GetAllAsync() =>
await context.Stores.ToListAsync();
+6 -3
View File
@@ -1,10 +1,11 @@
using Distribution.Features.Scheme;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Store;
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService)
{
private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20;
@@ -217,8 +218,10 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
// Wired to Step 17 (Promotions & Schemes) once scheme rules exist.
return new SchemeEligibilityResponse(true, null);
var result = await schemeService.CheckStoreScopeEligibilityAsync(id, schemeId);
if (result is null) return null;
return new SchemeEligibilityResponse(result.Value.Eligible, result.Value.Reason);
}
public async Task<StoreDashboardResponse?> GetDashboardAsync(Guid id, string period)
@@ -0,0 +1,10 @@
namespace Distribution.Features.Supplier;
public interface ISupplierRepository
{
Task<Models.Supplier?> GetByIdAsync(Guid id);
Task<List<Models.Supplier>> GetAllAsync();
Task AddAsync(Models.Supplier entity);
void Update(Models.Supplier entity);
void Remove(Models.Supplier entity);
}
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Supplier;
[ApiController]
[Route("api/suppliers")]
[Authorize]
public class SupplierController(SupplierService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[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(CreateSupplierRequest 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, UpdateSupplierRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
try
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/evaluate")]
public async Task<IActionResult> Evaluate(Guid id, CancellationToken ct)
{
var result = await service.EvaluateAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,13 @@
namespace Distribution.Features.Supplier;
public record CreateSupplierRequest(string Name, string? ContactInfo);
public record UpdateSupplierRequest(string Name, string? ContactInfo);
public record SupplierResponse(
Guid Id,
string Name,
string? ContactInfo,
decimal? PerformanceScore,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,25 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Supplier;
public class SupplierRepository(AppDBContext context) : ISupplierRepository
{
public async Task<Models.Supplier?> GetByIdAsync(Guid id) =>
await context.Suppliers.FindAsync(id);
public async Task<List<Models.Supplier>> GetAllAsync() =>
await context.Suppliers.Where(s => s.IsActive).ToListAsync();
public async Task AddAsync(Models.Supplier entity) =>
await context.Suppliers.AddAsync(entity);
public void Update(Models.Supplier entity) =>
context.Suppliers.Update(entity);
public void Remove(Models.Supplier entity)
{
entity.IsActive = false;
context.Suppliers.Update(entity);
}
}
@@ -0,0 +1,140 @@
using Distribution.Features.Grn;
using Distribution.Features.Po;
using Distribution.Infra.UoW;
namespace Distribution.Features.Supplier;
public class SupplierService(
ISupplierRepository repo,
IPoRepository purchaseOrderRepo,
IGrnRepository goodsReceiptRepo,
IUnitOfWork uow)
{
private static SupplierResponse Map(Models.Supplier s) =>
new(s.Id, s.Name, s.ContactInfo, s.PerformanceScore, s.IsActive, s.CreatedAt);
public async Task<List<SupplierResponse>> GetAllAsync() =>
(await repo.GetAllAsync()).Select(Map).ToList();
public async Task<SupplierResponse?> GetByIdAsync(Guid id)
{
var supplier = await repo.GetByIdAsync(id);
if (supplier is null || !supplier.IsActive) return null;
return Map(supplier);
}
public async Task<SupplierResponse> CreateAsync(CreateSupplierRequest request, CancellationToken ct = default)
{
var supplier = new Models.Supplier
{
Name = request.Name,
ContactInfo = request.ContactInfo
};
await uow.BeginAsync(ct);
try { await repo.AddAsync(supplier); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(supplier);
}
public async Task<SupplierResponse?> UpdateAsync(Guid id, UpdateSupplierRequest request, CancellationToken ct = default)
{
var supplier = await repo.GetByIdAsync(id);
if (supplier is null || !supplier.IsActive) return null;
supplier.Name = request.Name;
supplier.ContactInfo = request.ContactInfo;
await uow.BeginAsync(ct);
try { repo.Update(supplier); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(supplier);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var supplier = await repo.GetByIdAsync(id);
if (supplier is null || !supplier.IsActive) return false;
if (await purchaseOrderRepo.HasActiveForSupplierAsync(id))
throw new InvalidOperationException(
"Cannot delete a supplier that is referenced by an active purchase order.");
await uow.BeginAsync(ct);
try { repo.Remove(supplier); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return true;
}
/// <summary>
/// The only writer of Supplier.PerformanceScore. Computes a simple 0-100 score from this
/// supplier's own purchase-order history:
/// - lineFulfillmentRatio: received (non-cancelled, fully-received) Po lines / all non-active
/// Po lines seen (received + cancelled) - how reliably the supplier fulfils what it commits to.
/// - acceptanceRatio: AcceptedQuantity / (AcceptedQuantity + RejectedQuantity) across every
/// GoodsReceipt against this supplier's Pos - how much of what arrives is usable.
/// score = round(average(lineFulfillmentRatio, acceptanceRatio) * 100, 2).
/// Defaults to 50 (neutral) when there is no history yet, rather than hardcoding a fixed score.
/// </summary>
public async Task<SupplierResponse?> EvaluateAsync(Guid id, CancellationToken ct = default)
{
var supplier = await repo.GetByIdAsync(id);
if (supplier is null || !supplier.IsActive) return null;
var purchaseOrders = await purchaseOrderRepo.GetBySupplierAsync(id);
var receivedLines = 0;
var cancelledLines = 0;
foreach (var po in purchaseOrders)
{
foreach (var item in po.Items.Where(i => i.IsActive))
{
if (item.IsCancelled) cancelledLines++;
else if (item.ReceivedQuantity >= item.Quantity) receivedLines++;
}
}
var totalAccepted = 0;
var totalRejected = 0;
foreach (var po in purchaseOrders)
{
foreach (var receipt in await goodsReceiptRepo.GetByPurchaseOrderIdAsync(po.Id))
{
foreach (var item in receipt.Items.Where(i => i.IsActive))
{
totalAccepted += item.AcceptedQuantity;
totalRejected += item.RejectedQuantity;
}
}
}
var lineFulfillmentRatio = receivedLines + cancelledLines > 0
? (decimal)receivedLines / (receivedLines + cancelledLines)
: (decimal?)null;
var acceptanceRatio = totalAccepted + totalRejected > 0
? (decimal)totalAccepted / (totalAccepted + totalRejected)
: (decimal?)null;
decimal score;
if (lineFulfillmentRatio.HasValue && acceptanceRatio.HasValue)
score = Math.Round((lineFulfillmentRatio.Value + acceptanceRatio.Value) / 2m * 100m, 2);
else if (lineFulfillmentRatio.HasValue)
score = Math.Round(lineFulfillmentRatio.Value * 100m, 2);
else if (acceptanceRatio.HasValue)
score = Math.Round(acceptanceRatio.Value * 100m, 2);
else
score = 50m;
supplier.PerformanceScore = score;
await uow.BeginAsync(ct);
try { repo.Update(supplier); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(supplier);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,716 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddStoreCreditAndKyc : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.AddColumn<decimal>(
name: "CreditLimit",
table: "Stores",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<int>(
name: "KycStatus",
table: "Stores",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<decimal>(
name: "OutstandingBalance",
table: "Stores",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<string>(
name: "Segment",
table: "Stores",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "StoreKycDocuments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
DocumentType = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FileReference = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
UploadedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StoreKycDocuments", x => x.Id);
table.ForeignKey(
name: "FK_StoreKycDocuments_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000017"),
columns: new[] { "Code", "Description" },
values: new object[] { "stores.kyc.verify", "Verify or reject store KYC documents" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000018"),
columns: new[] { "Code", "Description" },
values: new object[] { "stores.credit.update", "Update store credit limit and outstanding balance" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000019"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.view", "View warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000020"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.create", "Create warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000021"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.update", "Update warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000022"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.delete", "Delete warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000023"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.view", "View orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000024"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.create", "Create orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000025"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.update", "Update orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000026"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.delete", "Delete orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000027"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.view", "View users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000028"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.create", "Create users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000029"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.update", "Update users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000030"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.deactivate", "Deactivate users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000031"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_role", "Assign a role to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000032"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_scope", "Assign region/route/store scope to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000033"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.view", "View items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000034"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.create", "Create items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000035"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.update", "Update items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000036"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.delete", "Delete items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000037"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.view", "View categories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000038"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.create", "Create categories and subcategories" });
migrationBuilder.InsertData(
table: "Permissions",
columns: new[] { "Id", "Code", "CreatedAt", "Description", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000039"), "categories.update", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Update categories and subcategories", true },
{ new Guid("20000000-0000-0000-0000-000000000040"), "categories.delete", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Delete categories and subcategories", true }
});
migrationBuilder.InsertData(
table: "RolePermissions",
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("c0739b84-50a2-0971-429b-02bab8354c51"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("c268a531-44eb-0f29-82ba-18394ac2dcb5"), true },
{ new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("ddb2d04d-19b0-20b1-2639-b157d12a9eec"), true },
{ new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("320ba3fb-666c-ce57-3e61-95c9824c4048"), true },
{ new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("f2c14d55-d557-5cd2-a70a-4a6f58933436"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("1b126bb0-e582-3e84-4728-a7ab5628fe7b"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("f50b201c-6c8d-0df8-85ad-08cb137bc213"), true },
{ new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("0f291826-e169-c06a-0735-bd06cbb2062d"), true },
{ new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("10dd0249-ce04-c5d9-8f94-ccb348c1d979"), true },
{ new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("d92d572f-f1bf-9e7d-1d4d-7c7b0d575c8e"), true },
{ new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("f367f6f6-3d26-7a6c-888b-da1a240b59af"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("929950da-d51b-ad5d-c4e5-55175908fb43"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a9a49749-f99d-3166-5d92-b7e380fcabb2"), true },
{ new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("b17df3df-022c-c903-6ce0-e6423e648dc8"), true },
{ new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a41f94ba-c24d-93dc-9f25-c462df19785a"), true },
{ new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e0b27435-f505-43f5-7ed9-4711f2248495"), true },
{ new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("5e10d61c-2d7a-3388-722f-45232d2cfb71"), true },
{ new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("d8257363-d838-fe7e-57a5-51b626dbfc83"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("04ece77e-b36f-8ed0-4ef9-2f6962b6f9c6"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("bf2a2f96-04ed-905d-5a8a-9047d814aa3e"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("4d287b19-ba40-7d4d-3d39-291be64197f8"), true },
{ new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("43a4c7b4-a50f-aa37-b147-a30636d9d65f"), true },
{ new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a66f3854-5a8f-95c2-cb1c-b2b2e67ee226"), true },
{ new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("14bcecd1-b1a2-6da3-155a-233611484467"), true },
{ new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("aeb29e0e-e819-1446-6cc6-1fb64b5ecc6b"), true },
{ new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e18a6eca-1009-bcc6-35e9-ead943c1649f"), true },
{ new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("3f32cdac-6925-23f2-064e-8226d781d4a3"), true },
{ new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("dc3dd9ff-b123-a8d4-dcea-b4f5f8ff171e"), true }
});
migrationBuilder.CreateIndex(
name: "IX_StoreKycDocuments_FK_StoreId",
table: "StoreKycDocuments",
column: "FK_StoreId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StoreKycDocuments");
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000006") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000006") });
migrationBuilder.DeleteData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000039"));
migrationBuilder.DeleteData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000040"));
migrationBuilder.DropColumn(
name: "CreditLimit",
table: "Stores");
migrationBuilder.DropColumn(
name: "KycStatus",
table: "Stores");
migrationBuilder.DropColumn(
name: "OutstandingBalance",
table: "Stores");
migrationBuilder.DropColumn(
name: "Segment",
table: "Stores");
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000017"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.view", "View warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000018"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.create", "Create warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000019"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.update", "Update warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000020"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.delete", "Delete warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000021"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.view", "View orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000022"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.create", "Create orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000023"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.update", "Update orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000024"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.delete", "Delete orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000025"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.view", "View users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000026"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.create", "Create users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000027"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.update", "Update users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000028"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.deactivate", "Deactivate users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000029"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_role", "Assign a role to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000030"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_scope", "Assign region/route/store scope to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000031"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.view", "View items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000032"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.create", "Create items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000033"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.update", "Update items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000034"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.delete", "Delete items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000035"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.view", "View categories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000036"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.create", "Create categories and subcategories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000037"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.update", "Update categories and subcategories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000038"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.delete", "Delete categories and subcategories" });
migrationBuilder.InsertData(
table: "RolePermissions",
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("85ef4e27-8643-9c43-71f6-da56f66a5c47"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("1e353909-d682-58f6-df5c-8e57fa2512c9"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("7596f703-c6ab-316c-7c80-3f4e3daa174e"), true },
{ new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("aa120c36-488e-34ba-e10a-2ac45e46e119"), true },
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e4648655-7267-141e-51fa-f7d5fe3d2356"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("d427b9ea-2c9b-d93e-0843-02dfa1ae8887"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("b7dee5e3-f329-95a5-75c1-d02c3e40e229"), true },
{ new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e5506b30-527d-08f9-6caa-8a45d3d02999"), true },
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("0dd5c943-3ace-0560-6fbc-54d8e37ce014"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("ca97fb3d-132c-d8a5-4ab8-f7b8fc60f5e5"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("7780d277-9597-84ff-1a1a-f63fc6b4d3cb"), true },
{ new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("8083c659-9506-0bd9-b793-3d6148ce9192"), true },
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("5c00e972-a697-d71a-6545-1fd8618826cf"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e9b6af8e-7947-7290-9c92-2d9b6239f395"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a701da28-aa75-3c1d-abdb-4317404f8df6"), true },
{ new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("2c025310-765f-7d07-ab9a-50524c13d1d3"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("69f93a29-1df1-7b20-9d5c-94c264f8c4b8"), true },
{ new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("070813f7-4053-2889-0d14-d0d5834208c2"), true }
});
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,257 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddProcurement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PurchaseOrders",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SupplierId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Status = table.Column<int>(type: "int", nullable: false),
CancelReason = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseOrders", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseOrders_Suppliers_FK_SupplierId",
column: x => x.FK_SupplierId,
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "GoodsReceipts",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ReceivedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_WarehouseId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ReceivedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GoodsReceipts", x => x.Id);
table.ForeignKey(
name: "FK_GoodsReceipts_PurchaseOrders_FK_PurchaseOrderId",
column: x => x.FK_PurchaseOrderId,
principalTable: "PurchaseOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_GoodsReceipts_Warehouses_FK_WarehouseId",
column: x => x.FK_WarehouseId,
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PurchaseOrderItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<int>(type: "int", nullable: false),
UnitCost = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
ReceivedQuantity = table.Column<int>(type: "int", nullable: false),
CancelledQuantity = table.Column<int>(type: "int", nullable: false),
IsCancelled = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseOrderItems", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseOrderItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PurchaseOrderItems_PurchaseOrders_FK_PurchaseOrderId",
column: x => x.FK_PurchaseOrderId,
principalTable: "PurchaseOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PurchaseReturns",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseReturns", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseReturns_PurchaseOrders_FK_PurchaseOrderId",
column: x => x.FK_PurchaseOrderId,
principalTable: "PurchaseOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "GoodsReceiptItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_GoodsReceiptId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseOrderItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
AcceptedQuantity = table.Column<int>(type: "int", nullable: false),
RejectedQuantity = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GoodsReceiptItems", x => x.Id);
table.ForeignKey(
name: "FK_GoodsReceiptItems_GoodsReceipts_FK_GoodsReceiptId",
column: x => x.FK_GoodsReceiptId,
principalTable: "GoodsReceipts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_GoodsReceiptItems_PurchaseOrderItems_FK_PurchaseOrderItemId",
column: x => x.FK_PurchaseOrderItemId,
principalTable: "PurchaseOrderItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PurchaseReturnItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseReturnId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseReturnItems", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseReturnItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PurchaseReturnItems_PurchaseReturns_FK_PurchaseReturnId",
column: x => x.FK_PurchaseReturnId,
principalTable: "PurchaseReturns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_GoodsReceiptItems_FK_GoodsReceiptId",
table: "GoodsReceiptItems",
column: "FK_GoodsReceiptId");
migrationBuilder.CreateIndex(
name: "IX_GoodsReceiptItems_FK_PurchaseOrderItemId",
table: "GoodsReceiptItems",
column: "FK_PurchaseOrderItemId");
migrationBuilder.CreateIndex(
name: "IX_GoodsReceipts_FK_PurchaseOrderId",
table: "GoodsReceipts",
column: "FK_PurchaseOrderId");
migrationBuilder.CreateIndex(
name: "IX_GoodsReceipts_FK_WarehouseId",
table: "GoodsReceipts",
column: "FK_WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseOrderItems_FK_ItemId",
table: "PurchaseOrderItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseOrderItems_FK_PurchaseOrderId",
table: "PurchaseOrderItems",
column: "FK_PurchaseOrderId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseOrders_FK_SupplierId",
table: "PurchaseOrders",
column: "FK_SupplierId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseReturnItems_FK_ItemId",
table: "PurchaseReturnItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseReturnItems_FK_PurchaseReturnId",
table: "PurchaseReturnItems",
column: "FK_PurchaseReturnId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseReturns_FK_PurchaseOrderId",
table: "PurchaseReturns",
column: "FK_PurchaseOrderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GoodsReceiptItems");
migrationBuilder.DropTable(
name: "PurchaseReturnItems");
migrationBuilder.DropTable(
name: "GoodsReceipts");
migrationBuilder.DropTable(
name: "PurchaseOrderItems");
migrationBuilder.DropTable(
name: "PurchaseReturns");
migrationBuilder.DropTable(
name: "PurchaseOrders");
}
}
}
File diff suppressed because it is too large Load Diff
+20 -6
View File
@@ -1,19 +1,33 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class DeliveryRun : Template
{
public RideType RideType { get; set; }
public RideStatus Status { get; set; } = RideStatus.Draft;
public Guid FK_VehicleId { get; set; }
[ForeignKey("FK_VehicleId")]
public Vehicle Vehicle { get; set; }
//public Guid FK_DriverId { get; set; }
//[ForeignKey("FK_DriverId")]
//public Driver Driver { get; set; }
public int Meterstart { get; set; }
public int Meterend { get; set; }
public Guid FK_DriverProfileId { get; set; }
[ForeignKey("FK_DriverProfileId")]
public DriverProfile DriverProfile { get; set; }
public Guid FK_RouteId { get; set; }
[ForeignKey("FK_RouteId")]
public Route Route { get; set; }
public Guid FK_SourceWarehouseId { get; set; }
[ForeignKey("FK_SourceWarehouseId")]
public Warehouse SourceWarehouse { get; set; }
public int Meterstart { get; set; }
public int Meterend { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? ActivatedAt { get; set; }
public DateTime? ClosedAt { get; set; }
}
}
@@ -0,0 +1,11 @@
namespace Distribution.Models
{
public class DriverComplianceConfig
{
public Guid Id { get; set; } = Guid.NewGuid();
public bool EnforceLicenseExpiry { get; set; } = false;
public bool EnforceMedicalClearance { get; set; } = false;
public DateTime UpdatedAt { get; set; } = DateTime.Now;
public Guid UpdatedBy { get; set; }
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class DriverProfile : Template
{
public Guid FK_UserId { get; set; }
[ForeignKey("FK_UserId")]
public User User { get; set; }
public string LicenseNumber { get; set; }
public DateTime LicenseExpiry { get; set; }
public DateTime MedicalClearanceExpiry { get; set; }
}
}
+23 -1
View File
@@ -18,6 +18,28 @@ namespace Distribution.Models
CustomerReturn,
Transfer,
Damage,
Adjustment
Adjustment,
Load,
PurchaseReceipt,
PurchaseReturn
}
public enum LoadingSheetStatus
{
Pending,
DispatchConfirmed,
ReceiptConfirmed,
DiscrepancyFlagged,
Resolved
}
public enum RideType { Delivery, Sale }
public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed }
public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other }
public enum CancelRequestStatus { Pending, Approved, Rejected }
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
}

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