Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bc33ce08f | |||
| 870b412a06 | |||
| 095d35e1b8 | |||
| d7b03941fb | |||
| 81a49e1a98 | |||
| 2995ce7012 | |||
| db0850f1f0 | |||
| 702dbeb39f | |||
| c8d23afe59 | |||
| ac1b2fbc4b | |||
| f381e7a634 | |||
| 6a0322570b | |||
| dc4b1934e1 | |||
| aabace1bba | |||
| f7101800cd | |||
| c9f5006727 | |||
| 942a39b0eb | |||
| b2681d1236 | |||
| 63bc6e9cd8 | |||
| 7a2494c156 |
@@ -0,0 +1 @@
|
||||
False
|
||||
@@ -0,0 +1 @@
|
||||
a3dd8e4b-8c69-4536-b080-24506bc95dea
|
||||
@@ -0,0 +1 @@
|
||||
614d490d-41ba-47f9-ae3a-b56948b3d6e4
|
||||
+5
@@ -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=
|
||||
+5
@@ -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=
|
||||
+5
@@ -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=
|
||||
+5
@@ -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==
|
||||
+5
@@ -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=
|
||||
+5
@@ -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==
|
||||
+5
@@ -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
|
||||
+5
@@ -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
|
||||
+5
@@ -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==
|
||||
+5
@@ -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
|
||||
+5
@@ -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==
|
||||
+5
@@ -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=
|
||||
+5
@@ -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=
|
||||
+5
@@ -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=
|
||||
+5
@@ -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
|
||||
+5
@@ -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=
|
||||
+5
@@ -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=
|
||||
+5
@@ -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
|
||||
+5
@@ -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=
|
||||
@@ -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 }); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 < 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(); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
-2338
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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+1754
File diff suppressed because it is too large
Load Diff
+612
-251
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
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user