Merge pull request 'Dev' (#27) from dev into production

Reviewed-on: #27
This commit was merged in pull request #27.
This commit is contained in:
2026-09-20 10:35:40 +00:00
158 changed files with 26212 additions and 3857 deletions
+85
View File
@@ -0,0 +1,85 @@
name: CI/CD - ERP Distribution
on:
pull_request:
branches:
- production
types:
- closed
jobs:
build-and-deploy-backend:
name: Build & Deploy Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.DISTRIBUTION }}@gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution.git \
.
- name: Restore
run: dotnet restore Distribution/Distribution.csproj
- name: Publish
run: |
rm -rf /tmp/erpdistribution-deploy
mkdir -p /tmp/erpdistribution-deploy
dotnet publish Distribution/Distribution.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpdistribution-deploy
- name: Stop service
run: systemctl stop erpdistribution.service
- name: Copy to deploy folder
run: |
mkdir -p /home/distribution/core/publish
rm -rf /home/distribution/core/publish/*
cp -r /tmp/erpdistribution-deploy/* /home/distribution/core/publish/
- name: Fix ownership and permissions
run: |
chown -R distributionuser:distributionuser /home/distribution/core/publish
find /home/distribution/core/publish -type d -exec chmod 755 {} +
find /home/distribution/core/publish -type f -exec chmod 644 {} +
chmod +x /home/distribution/core/publish/Distribution
- name: Restart service
run: systemctl restart erpdistribution.service
- name: Verify
run: |
systemctl is-active erpdistribution.service && echo "✅ Service running" || echo "❌ Service failed"
build-and-deploy-frontend:
name: Build & Deploy Frontend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.DISTRIBUTION_FE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution_FE.git \
.
- name: Build Docker image
run: docker build -t erpdistributionfe:latest .
- name: Stop & remove existing container
run: |
docker stop erpdistributionfe 2>/dev/null || true
docker rm erpdistributionfe 2>/dev/null || true
- name: Start container
run: |
docker run -d \
--name erpdistributionfe \
-p 8801:3000 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpdistributionfe:latest
- name: Remove old images
run: docker image prune -f
- name: Verify
run: |
docker ps --filter name=erpdistributionfe --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpdistributionfe && echo "✅ Container running" || echo "❌ Container failed"
@@ -0,0 +1 @@
False
@@ -0,0 +1 @@
bf7d0202-9a86-43b3-8323-402073a8c61a
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTyW7bMBD9FYFnUaFIaqFuSeygRpDaiI226I3LKCaikAZFZUHgfy8oO0jTWy8EMeCbecvwHTn5BKhDl4fD8hlcHFGOop1LlNAaE4HLZkeqjrcdF0VZcUoZ+41yZG/hDXWo4VqXWgBmUAPmhlRYsV5jY0CUpDSEVjz1lA8j6t6RtIWBZ6uh8OMPCKP1DnXop3XGv6TZ0hZ68JMpgh9g5cYonU5kFsvt7W69wTdN/X15dXV6OcKYGhTWoA61VUW10AJzISrMdcOx7BuKe6r6UpVNRQScYNZFCE4OxWgePzkYHx1E3dGCsoJgKtAxR0ZGmWgrOcLu7ZCYzDYtUj2fy/O1e0fPEFBH8w9DT+0u9GAvnkY12cFcnM4ox8ek9BD8AUK0MPsySx0GGa132XnS9WDBRZSj9TbbDDL2Pjx9MWt70r/6D/m7NH35CnqKYK795GLCohzdSb23DrLVAnWoLrnhghjMSyUxb3qBJTCJVVUL3ipmakihXnsXrZv8NGYrF+EhzPRRh27kMKZp6212GfTeRtBxCknTrzoBN8GbScfs0/2SFKRghCXQFA9TzO7B2AA6QlK3C9O53z8QWlNCEpUpBHAx28i4z77JcZ8emMZUmgKYkqmGqB6q1rRVqxtT123da15pwspKc65Zr4yQSgmmKae94tDUKEeQws7m/Wr6viW1ajEXjcJcMo6VIBzzsq4kV9JUMiE+fczWQ8KpvjGEEoqFbGvMmWK4ZZRhTihpmGx1XaZV+rDknD1PmdxCcDD8JfnO6uBH38fsvATZFxPuJ5f+bjYvxIt1+HV2e+H1I4QspSWtS2t6Duh4PB7/AAAA//8DAAY6R+UCBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTyU7dMBT9lcjrOHiKHWfHqCJEQYDaqjvHvuFZBPvJcRiE3r9XDg9RuuvOuvI5PsP1GwrmEVCPDrfb0ycIeUY1yn4dMcIkJhpTdUfaXnS90A2VHVdK/UY18hfwinqkhLXUasAcJGDhSIsHPlrsHGhKqCOsFYXT3M+of0PGNw6evIUmzj8gzT4G1KOfPrj4XN42vrFTXFyT4gTnYc4m2CLm5PT24u7qGp8p+f306Oj95gxzIWi8Qz3q2pZZbTUWWrdYWCWwGRXDIxtGOlDVEg3vMB8ypGCmZnYPnxpczAGy7VnDeEMw02hXI2eyKbIHM8Pd67YoWWM6KfN6Ha/H/g09QUI9qz8Cfac7sJM/sPHx0QR3MPrg5w2q0TbFLaTsYY1kdTlNJvsYqv0jx5OHkFGNrm6r68nkMabHLzndvls//w/nl8ZufIDq/AT1SFLhhCYOCzoYLNSosQFu8NBKLbqBOwmltuMYsg9LXObqPGS4T6tK1KMzM82F9Oq2Okx24zPYvKQi/ZcsQHjx+Ti6MiCoRtcpusXm6jNuShrScMILx5K3S65uwPkENkPxdJeWPf0/ECYZKYzHS0oQcnVt8qb6ZuZNueCUay0DcJQPigwjtJ3r2s4qJ2UnRytaSzhtrRCWj4PTZhg0t0ywcRCgZNFd2q3WhQJCmNCK4lGqFgupKDbcScwJjAoYpa4riM9Yq6up4IZROcIIw9p0Egs+cNxxxrEgjChuOitp2Z2PSPaNiw7V6AJSgOkvy5fepjjHMVf76qsvIdwsoXzWal2DZx/wyxr+SbQPkKpSnvGh7OW+r91ut/sDAAD//wMAfxRohPMDAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTS0/jMBD+K5HPdXBsJ3FyA1q0FWKpoNpd7c0eT6hFcCrH4SHU/75yWsSyt73E0cgz8738Trx+QtKS8/1+9Yw+jmRBoptLnPGKsoYW9ZaVrVStbHKlmkpU8jdZEHeNb6QltQQooEEqsEIqLSupER1Qa7EpWGEZL2WaqR9G0r4T7XKLzw4wH8YfGEY3eNKSn87b4SXt1i6HfphsHoYe136M2kMCs1zdX29vN/Sqrr6vLi6ON0cc04DcWdKSqkOjrC5p3TRAJTSGqrqpqdaisx2W6Ty2OR8xeN3no338xGCH6DFCy3MuckZ5Qw4LYnXUCbbRI27f9gnJLNMy1Rdzef5t38kzBtLyxYegx3Fn0Luzp9FMrrdnx2/U42Niug/DHkN0OOsyU+17Hd3gs9Omy96hj2RBbu+zTa9jN4SnL2LdH/mv/4P+Nm1fvSJMEe3lMPlIWqLIgtxo2DmP2XqZhhXSyoZZKgujqay7hmoUmpqyaqQywlaYTL0cfHR+GqYxW/uID2GGT1pypfsRj8DPA+xcRIhTSJx+ValxEwY7Qcw+1S9YznLBRGqa4n6K2R1aFxAiJnbbMJ3m/dPCK85YgjKFgD5mGx132Tc97tIFW9sSOKIthKmZ6bBUVpUKaltVqupAlsBEUYKUIDpjG21MI4BL3hmJdUUWBJPZ2ZwvbQ2zrKip6rim0uqCKgBGrapBAGCBUHzRMbvtU5/pass447TRqqJSGEGV4IJKxlkttIKqSFH6kOTkvUyeXGPw2P9F+cZBGMahi9kpBNkXEe4mn95uNgfixXn6Oqu9HOARQ5bc0s6nmJ4MOhwOhz8AAAD//wMACwdnnQIEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTS2/cIBSF/4rF2jgYsA3e5alGUZooGbVVdzyuExQHRoDzUDT/vcIzVZvuusHoinM5fOf6A3n1DGhEx9vt+Qv4nFCNsltLlNAeE4nbYUO6kYuRy0aInhJJfqIauSt4RyMauDGtkYAZ9IC5JR3WbDLYWpAtaS2hHS891UNC4wdSrrHw4gw0IX2DmFzwaETfnbfhtdytXGPmsNgmhhkufcrKm2Lm7Pz+anNziy+G/uv5ycn+ZIJUGjTOohH1E2hhVYcHKQ3mRmosBjlgpdhkJ+jKdy9zPkP0am6SffrjwYbsIZuRNpQ1BFOJdjWyKqtiW6sEm/dtcbJiOiv1ei2v2/EDvUBEI61/A923OzKzO3pOenGzPdqvWaWntK6TMjnEd1SjbQxbiNnByug4JXjW8/tGpaeL/ZmyTedvYJYM9jQsPqMRCVSjFdE8q+yCrw4OT2cHPqMa3dxXt7PKU4jPnyDf77ld/ge2a2UenYfq8qxoWm65JBbzVivMh0liBUxh3fWSC81sDyXz0+Cz80tYUnXpMzzE1SUa0YWaE+z9HUfz6DKYvMRi/UdfhLcx2MXk6k84LWlIwwgroiVvl1zdgXURTIbyiE1cDv3+kdCeElKsLDGCz9Wtyo/VF5UeywE72M5QANsyPRA9QSes6IQZbN+LfjK8M4S1neHcsElbqbSWzFBOJ81h6FGNoMxCtY6fsUQTbQQeqLGYc9liwcSAmSJaU2HEpOUnjtXNXHR6GiyhhGKpRI850wwLRhnmhJKBKWH6tkzabySHiHnJ/gqih/mvJ187E0MKU64OWVefINwtvvza1Zr7q/P4baV9FswTxKqkpZwvU3wIaLfb7X4BAAD//wMA4dUdUiEEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyhTJMUP3ZLYQV0j9SJeNEVvQ3JkC9FSC4raNDD2vxfUbuCmt5xIDPgOZx6+nFcS4YCkJ++Ox7sTxryQmuRxC3HGFWWWtnrPul6aXtpGWGN5K/8mNRkf8DvpiZbet94iFaiQysA66sTgaQhoW9YGxjtZcsLzQvpXAmMT8DR6bOblT0zLOEfSky9jDPO3cjeMjZ/mNTRpnvA+LhmiL8Xc3j097B939KNWf9y9f385ueBSEjRjID1RAzoToKPaWk+lt44abTUFEEMYsCvrRTbGjCnC1Czh61sNYc4Rs+95w0XDKLfkXJMAGUrZDhbcfz+WSjZMtyVeb+Ft27+SEybS8/oH0Eu6Gz+NN3k+TnjC6QhpwXTj58MBYiA1Oab5iCmPuKHZup0myOMcq+tlH6YRYyY1eXyqdhPkYU6Hn3g9XRDc/wKBEyZHetKZDkSrEWQwaMOgOuS2E6ZlKCUIzQODYZBK+KCYVQMCD156r1vOgxWK1OR5mh1Mvy8bP8uZM4FprjhKtFwqZT1DYXDoNLIASjllvA3CaC5NMAIsQGsdam+VLyb5BP5ljFjd35ZuWhmkZYHK1gGVerAUUAB1nbLSOBEUFs2HOeYxrvO6VPcx43Pa+JGefIRpwQu5d8m/jBl9XlOB+pcqwl2aw+pz9eaAljWsEUwU0ZqPa64+YxgT+owF7z6t13z/k3DFGSulrClhzNUO8kv1Gywv5UDQofMcMbTCaeYG7EwwnfE6KGXU4GXnmWg7L6UXgwsWnLPCc8kHJ1EXylgMV20ed8xLaNFT7V1HJVhDQYKhxjDDW9VxZouv3jhWj9OmG3RgnHFqwSgqhRPUCC6oZJxpAcarttj5B5Kr+aQhNXnAFHH6T8ufRp/mZR5ydXVh9ROEz2ss86PaHPltjPSfjfbt7L9iqsprwRjLV7k+0LkmB4RlTXjYxk//Snbln1T7bQpx1TBjTE2eMqS8Hq/hrmsMk+fz+fwvAAAA//8DALVuXjbCBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRU207kOBD9lcivGwffEtt5A7rRIsRF063d1b75UgEvaaflODAI9b+vkoYBZrQP+xJHJVfVqXOO6xVFswPUotP9fv0EMY+oRDksIUZYg4nGVG5J3QrVCl0pzZlk9d+oROEKXlCLpHCOOg2YQwNYeFJjyzuHvQdNCfWE1WKuae5H1L4iEyoPT8FBNYx/QBrDEFGL/gzRD89zbxMq1w+Tr9LQw2Ucs4luBrNab662t3f4QjY367Oz480RxrlAFTxqUdOBVd7UWGrtsHDaYiW1xMbwzndQz+cxLcQMKZq+Gv3jBwY/5AjZtaxivCKYaXQokTfZzLCtGWH7sp+RLDSt5ni5hJff9hU9QUItK98JPZY7cX042Y12Cr0/Wb6oRPs07CHlAAsjy5B9b3IYYvHW47wPEDMq0dmcspmcg3FELdqmCd6j63gfIlwks4PnIT3eHNtWN+stKtFdGv4Bl+9MfkAt4p1TvrOOGqFtZ8HrhtQGtOmEFarznlPXOUY70khpmei48taappG1t5YxVKLbTXHXm9wNafdFr81Rgsv/ocDm9Hwdje1hzrkw/fhjpK1J95BRiyylnVNSa+FZrT0H4xQI2lhPjFIcBHgute6o4pxxRTtFrNKmVsTWIDxHJbocN9lEb/ohwvo7uGnm94PCa+MeQoTicjUDp8ILTTwW1BosZKexAW6wrRstlOW+gdnD50PMIU7DNBaXMcN9WjT7NMTtpjhN7iFkcHlKsxx/NXPiXRr85HLxYTZKKlJxMgO9nfJ+ysU38CGBywsrbyBvNz+nsIYRMkOZUoKYi1ng4nczzipTL33tGICn3EpiO6iVV7Vy0jeNajonakc4rZ0QjnfWa2Ot5o4J1lkBskElgtnbxfKcrGkceEewB6OwUKrBhmuPQVsKXBkpmPjCY3HbL3md9IQRhrVRDRbccqw441gQRiQ3yjXUfLXwpxFV1VRUVFyqhZp33t5ehVCoRFeQIvSfeLkOLg3j0OXizZXFF6Y+NVqFcd+bl1/6/QaSk456K9/vnz+Ae/zVpN+mOO/GYnH7c4j4+yLvanCPkIrZHibEeQ38yLie+hy2DwmMB389ePi56KFEOzDjlGC3bN9lIURIC4zVdLTYZbwOfR9GcEP0I2oFUxUlgr6h/c9rmlVSE3U4HA7/AgAA//8DADlZ+rXpBQAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxTTW/bOBD9K8KcRYUSqQ/q1sQJNkhbB7V3u9gbP0YOEYU0KCptEPi/F5Sdtm572JOE0ejNm/fevIKTTwg9vNvvr5/RxQlyiHYpVbRqCBWkbLe07nnXc1Hwpu2qlv4HOdg7fIEeWq51qQUShg0SbmhNFBs0MQZFSUtDq5onTLmboH8FaQuDz1Zj4ad/MEzWO+jhs3XGf0mzpS306GdTBD/irZuidDqRWV1v7rbre3LTNh+vLy+PnRNOCaCwBnpoBlSdkTVphdCEa6FI14qWSMkGM2CdnsffrIsYnByLyTz+4GB8dBh1XxUVKyipBBxyMDLKRFvJCbcv+8RkkWmV6vlSXl77V3jGAH2Vvwl6hLvQo714mtRsR3Mx+t3Out2Vd4PdzUHGNDiHffB7DNHiItB6k/3gVNKCFlVTUQo5fJD6wTrMblfZekwbq6E1tKIVEbJrCGeKkY5VjHBa0ZbJTjdlInmHweH4E+oHq4Of/BCzk+7Z2Zwr76J1s5+n7NZF3J2I9nAjxwlTwxwCupjdy/iQ/SWnh8TUtKbWFaIpmWqpGrDuTFd3ujVN0zWD5rWmrKw155oNygiplGC64tWgOLYN5LDF8GSdHN/73S5p+X3e+YcVDnIe48bPYUnGZB4hh0vrZHg5dvw9oTl1fTya8Ya0BGocl4Wyk59Xo0UXz/RNaSq54YIawkslCW8HQSQySVTdCN4pZhpMsb4P3sw6/moZowxy+DS7dErZbTLri3Xka8N/ofoTN0y5ypYoCymVarQgjFEkvCkrIriQhCGTkg+a8UFADiuvHzFkyS9p3RnYepO9C/rBRtRxDmnNf5fZ6012P8o4+PB0dnbrOe7nmH1CYwPqiInFNswJ6saO+BvZt71PGvIOctgcr3HZ9n8e45V3k/8D/B8dhx7kHD0cDofDNwAAAP//AwDB5+uJuAQAAA==
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTy07cMBT9lcjrODi248TZwQyoCFEQjNqqOz9uGItgjxyHh9D8e+XMIEp3XcW68jk+j5t35NUToB6d7nbnz+DThEqU3DKihApMJK7bDWl63vVcVpIwSrr2NyqRu4I31KOWG1MbCZiBAMwtabBmg8HWgqxJbQlteOZUDxPq35FylYVnZ6AK0w+Ikwse9ein8za85LeVq8wYZlvFMMKln5LyJotZn99fbW5u8UUrvp+fnR1uTjBlgspZ1CMxgO6sanArpcHcSI27VrZYKTbYAZr8PcCcTxC9GqvJPn5qsCF5SKanFWUVwVSifYmsSirL1mqCzdsuK1liWud5uYyXY/+OniGinpYfgR7oTszoTkx4elLengzOu2mLSrSLYQcxOVgiWVyOo0ou+OL4yGp04BMq0c19cTuqNIT49CWn+4P1y/9wfq3M1nkoLtcZU3PLJbGY11ph3g4SK2AK60ZI3mlmBeTaVsEn5+cwT8WlT/AQF5WoRxdqnOCg7zSarUtg0hyz9F8iA+HVpVWweUBQiW5jsLNJxWfcNalIxQjLHHPazam4A+simATZ0ybOR/p/IFRQkhlXc4zgU3Gr0rb4pqZtvmBb2xgKYGumW6IHaDrbNZ1prRCdGAxvDGF1Yzg3bNBWKq0lM5TTQXNoRdad2y2WhSINlxKswMKQBnMuNdaSMtxo0tq2YVTorP4z1uJmzDg9tJZQQrFUncCcaYY7RhnmhJKWqc6IOu/ORyTHxnmHSnQF0cP4l+VrZ2KYwpCKY/XFlxDuZp9/1mJZgxfn8esS/jqYR4hFLk85n/fy2Nd+v9//AQAA//8DAB8jFwPzAwAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUS2/dNhCF/4rAtSjzJT60S2IHdY3URmw0RXdDcmQL0aUuKMppYNz/XlC+gZvusiMGPMPDj4fzQhIckAzk3fF49YyprKQlZdpLgglNmaPcPLB+UHboWccsF9rwv0lLphv8TgZiVAg8OKQSNVIVWU+9HAONER1nPDLRq9oTHlcyvBCYuojPU8BuWf/EvE5LIgP5MqW4fKtnw9SFedlil5cZr9NaIIVq5vLq/ubh9o5+NPqPq/fvX3euuNYG3RTJQKKzIVghqTGipwowUDcCUAAZIcKoneOvsikVzAnmbo1f3zzEpSQsYRCdkB2jwpFTSyIUqLY9rPjw/Vid7Jgua73dy/tyeCHPmMkg2h9AX9tdhHm6KMtxxmecj5BXzBdhORwgRdKSY16OmMuEO5r9tvMMZVpScz7swzxhKqQlt/fN3QxlXPLhJ173rwiuf4HAM2ZPBiJZL7UxqKSU4LQXDKPRQWvhAzAzeoE8IOudMo4bxqUTzMleG2siMBdISx7nxcP8+7rzc4J5G5kRWqBCJ5TWLjCUFsfeIIugtdc2uCitEcpGK8EBcOfRBKdDDcknCE9Twub6kgxEcxWVY5Eq7oEqMzoKKIH6XjtlvYwaq+bDksqUtmVbm+tU8DHv/MhAPsK84iu5dzk8TQVD2XKF+peuwru8xC2U5i0BnHWsk0xW0VaOW2k+Y5wyhoIV70Pezv3+JxFaMFatbDljKs0dlKfmN1if6oZoYh8EYuTSG+ZH7G20vQ0mam31GFQfmOR9UCrI0UcH3jsZhBKjV2g0aQnWwDV7xsMYjRo9Uo9CUqUsp1b3hkouNToEiLuRN47N7Vx1fjSRCSaoA6upkl5SK2sHJpiRYIPmNc4/kJzDpyxpyQ3mhPN/rvxpCnlZl7E05xQ2P0H4vKU6P5o9kd+mRP/ZaV8u4Svmpr4WTKl+lfMDnVpyQFi3jId9/Awv5K7+k+Zhn0JCdZpz05L7Arlsx3NZ6c4xrU6n0+lfAAAA//8DAMJGy6DDBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUy27dNhCGX0XgWpR5v2iXxA5qGKmN+CAtuuNlFBPRoQ4oymlgnHcvKJ/ATXfdEQPO8J9vfs4Lyu4IaETvTqebZ8h1RT2qaQ8xwhQmFlN9IHIUZpRkoMRYQsVfqEfpDn6gEWkRAg0WMAcFWEQisedTwDGCpYRGwqRoNd3XFY0vyKUhwnMKMCzrFyhrWjIa0R8px+V7e9ulIczLFoeyzHCb1+pyaGKubx7vDvcP+KNWv9+8f/96c4W1FRhSRCOK1oRgGMdaM4mFg4Dt5Bx2jkcX3aSspa9pKVco2c3DGr+9aYhLzVDDyAbGB4KZReceRVddk+3dCocfp6Zkx3Td4v0e3o/jC3qGgkbW/wT6Wu4qzOlq3fwMzzCfXFmhXIXleHQ5oh6dynKCUhPsaJ6h+GVNtVENQTtjKI3GaSF1NFwyriMnEA3RMtponRJqYkHKKXpjtZhEZFREBUJ4jXq0w5tnV9OSu4v2D3OCXFGP7h+7h9nVaSnHX/A/vhK9/R9Am2w0Ik4kV1qD4Jw7qzwjELUKSjEfHNGTZ0ADEGmFtlQTyi0jlkuljY6O2IB69MmFp5Shu71GI1JURGFJxIJ6h4WeLHbAHfZSWWE8b42iHn1Yck15W7a1u80Vvpa9XzSij25e4bXTdyU8pQqhbqVB+FO1xIeyxC3U7s0AlAxk4IS3pK2ettp9hpgKhAoNx6Fsl3r/SWGKEdKkbKVArt2Dq0/db259aheijjIwgEi518RPIE000gQdlTJqCkIGwqkMQgQ++Wid95YHJtjkBWiFegTNb91u8TBJyi112BhtsIheYi8Nw0QqYo0xOoo2kTeO3f3c8vykI2GEYeuMwoJ7jg1nHAvCiObOBEWbm38iuZhFGNSjOygZ5n+1/CmFsqzLVLuLa7pfIHzeclsf3e6g7ynjv3fa10v4BqVr03Ipt59yGdC5R0dw61bguG+f8QU9tG/SHfYlxMSgKNU9eqyu1O10CQs1WKLE+Xw+/wMAAP//AwC8ws6OwgQAAA==
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoyUTW/bOBCG/4owZ1GhSOrz1sQJNsi2Tmu3u9gbRY4cIgppkFS6QeD/vqDsbOp2D3sTRqN3nnlnRq9g5RNCDx/2++tntDFADtEsIUZZTWhHymZLq160fUWLsmmrqhJ/QQ7mDl+gh0YoVaoOCccaidC0IgMfFdEau5KWmrJKJE25C9C/gjSFxmejsHDhG/pgnIUe/jBWu++ptjSFmtysC+8mvLUhSqsSzOp6c7dd35Obpv50fXl5zAwYkkBhNPSgu1aplnHSNKwiQqIi3SglkZJrqeVYd115/MzYiN7KqQj68Z1Bu2gxqp4VjBeUsA4OOWgZZcIeZMDtyz6RLDatUjxfwstj/wrP6KFn+ZuhR7kLNZmLpzDMZtIXk9vtjN1dOTua3exlTIVz2Hu3Rx8NLgatN9k7U0kLWrCaUQo5fJTqwVjMblfZekodD2OjKaOMdLKtieADJy1nnAjKaMNlq+oyQd6htzj9oPrRKO+CG2N28j07q3PlbDR2dnPIbm3E3Qm0hxs5BUwJs/doY3Yv40P2mwwPiVQ3ulIMUZd8aOgwYtXqtmpVo+u6rUclKkV5WSkhFB8H3clh6Lhigo2DwKaGHLbon4yV0+9ut0te/lvv/MUKRzlPceNmv2xG0I+Qw6Wx0r8cM74G1KesT8dhvCktCzVNS0PZaZ5Xk0Ebz/yFHupSaNFRTUQ5SCKasSMSuSRDVXeiHbiuMa31vXd6VvHnkXHKIYcvs02nlN2mYX03lvxdi59Qf2DDtFfZssp1Q5EPoyZIO0GEGJFIHCnRVOMoq6qsaAc5rJx6RJ+leUljz8TWm+yDVw8mooqzT23+udReb7L7ScbR+aezs1vPcT/H7Atq41FFTBRbPyepGzPhL7BvfZ88FO1xb4J7y/2GfnDBxPSL+DwbTAZvjue62PE/r/VM853pPzcCepBzdHA4HA7/AAAA//8DAL7u70zYBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUy27jOBD8FYFnUaFI6mVgDxknwQbZjI3Ys7vYGx/NmIhMGhSVTBDk3weU5YkzmMPeCLIfVdXVfENO7AEt0OXhcP0MLg4oR9FOV5TQGpMOl82WVAveLipS1C1rSMn/Qzmyd/CKFqjhSpWqA8ygBsw1qbBkRmGtoStJqQmteKopHge0eEPCFhqerYLCD39DGKx3aIH+sU77l9Rb2EL1ftRF8D3cuiEKpxKYq+vN3Xa1xjdN/fX6y5dj5ABDKlBYjRZId61SLWW4aWiFuQCFOyMEFoJpoYWpu648plkXITjRF4N++sCgfXQQ1YIWlBUE0w6950iLKBJsKQbYvh4Skkmmq3SfT9fTcfGGniGgBc1Pgh7LXajeXuwHOdpeX0QRHiGaIPbw4sMTPIse5egQ/AFCtDDps9pkH5BKUpCC1pQQlKN7oXbWQXZ7la36RFiaRhNKKO5EW2POJMMtowxzQknDRKvqMmG8g+CgP6t6b1Xwgzcxm2XPPvVZehetG/04ZLcuwmMQ8Zh3I/oBUI7Wo+ztsHsAoV+3/mFMb27sE5flGAK4mK1F3GV/imGXSOhGV4oC6JLJhkgDVavbqlWNruu2NopXirCyUpwrZqTuhJQdU5RTIzk0NcrRaoyHMZ70/34GYhvsfg/6A8F20njdi2h82N9qcNEam0ZziphM1fcTq2yuuewtuPhJZLRAdck174jGvJQC88Z0WAATWFZ1x1vJdA3J2kvvjH0cf+pEjNGcmapta9nJkgA1TctJYyTRyjDV6UpCQxvStNB2ihBZ8bKVnSIayorrxC54Par4qxUYYShHD6NLG5rdJtov1uHvNf9QZB189Mr3aWC2h83rEGH/u6kt/f7gBxsTfzOP9tsA996l1/kPmDWbky99PAuGtAjZtHtJFg2c4k7VDHNtGiwp0biuW97UXDDQHcrRlVdPELLkMGHdNJSTqVab7DKonY2g4hhS638nVsd53px25kOQ4uv1dukDXB4O+Xz7x3NXJAevNtnJAJ/+lm9Dit75IbGIYTzz0QZ6c4J15qaj8bIH0DaAitPT9pg4a/Q7h12GaI1QcUhr8JdXkzFmp80hv/Y7G8DP7GPzsyHMppgr8XaqM/2AkxX+1wf4/v7+/gMAAP//AwCxdSWE9QUAAA==
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTU/cMBD9K9Gc4+DYjvNxoyxVEWpBsGqr3ib2pFgEZ+U4fAjtf6+cXUTprRfLGvnNvPfm+RU8PhB0cLrbnT+SjzPkEN1aElxoxltW1ltedarpKl00Uopa6l+Qg7ukF+igVsaUpiUmSRNTllesl4Nh1lJb8tJyUanUE3/P0L0CusLSozNUTPN3CrObPHTww3k7PaXZ6AozTostwjTShZ8jepPIbM5vL7dX1+xzrb+df/p0eDnTnBoUzkIHtm2MaYRkdS0qppAMawdEhigtWhx025YHmPORgsexmO39Owc7RU/RdKIQsuBMtLDPwWLERLvHmbYvu8RktWmT6vlaXq/dKzxSgE7kb4Ye2p2Y0Z08zP3iRntyOCPO90npLkw7CtHR6ssqdRwxuslnx0lnoyMfIYer2+x6xDhM4eGDWbcH/Rf/IX+bpp8/k1ki2bNp8RE6UAJy+IrmznnKLjbQgS6VVS23TJU9MlUPLUOSyPpKt6rppdWUtno2+ej8Mi1zduEj/Q4rf+jgM44zHZifBnPnIpm4hCTqp07A6zDZxcTs3f6SF7yQXCbQEndLzG7IukAmUpK3Dcux3z8QoQXnicoSAvmYXWO8y77gfJce2NpWRhDZUvY17weqGttUjamt1o0ejKoMl2VllDJy6G2Lfd9KI5QYekW1hhwobTtbA9YS15wImUbNmdKVYNhzYkZz5FXbN7ZMmXj3MbsaE64fassFF6zFRjMle8kaKSRTXPBaYmP0inuz5Lh81UAOlxQ8jX9J/upMmOZpiNkxBdkHE24Wnz5vtibiyXn2vLq9mcw9hSxtC51POT0uaL/f7/8AAAD//wMAd4cViwMEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTXW/bIBT9KxbPwcWAMfitbVotqrpGbbRNe8Nw3aA6EGHcD1X57xNOqq5725t1xTn3fFy/I693gFp0vt9fPYNPI1qg5OYRJVRgonDVbEjdctnWopSMM0bVb7RA7gbeUIsabkxlFGAGAjC3pMYd6w22FlRFKktozTOnfhxR+460Ky08OwNlGH9AHF3wqEU/nbfhJe/WrjRDmGwZwwArPybtTRazvHq42dyt8XUjvl9dXBxfjjBmgtJZ1CKrpDGSMtw0tMZcg8Gq1xprzay2uhdKVUeY8wmi10M52qdPDTYkD8m0tKSsJJgqdFggq5POsjs9wuZtn5XMMS3zfDGP58/2HT1DRC1dfAR6pDszgzszYbfT3p71zrtxixZoH8MeYnIwRzK7HAadXPDFacnl4MAntEB3D8V60KkPcfclp4ej9dV/OL/VZus8FKslapGouOWKWMyrTmPe9AprYBp3tVBcdswKyLVdBp+cn8I0Fiuf4DHOKlGLrvUwwlHfeTRbl8CkKWbpv0QGwqtLl8HmQd69jsFOJhWfcVekJCUjLHNMaT+l4h6si2ASZE+bOJ3o/4FQQQnJyqYYwadirdO2+KbHbX5gG1sbCmAr1jWk66GWVtbSNFYIKXrDa0NYVRvODes7q3TXKWYop33HoRFZd263mA+qMY1QnQbMOasxZ12HOwUWV5KQCqSESvdfYi3uhozr+sYSSihWWoqMY1gyyjAnlDRMSyOqfDsfkZwa5xIt0A1ED8Nflm+diWEMfSpO1RdfQriffP5Zi/kMXpzHr3P4y2CeIBa5PO18vstTX4fD4fAHAAD//wMA/WXCnPMDAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUy27kIBD8FYvz4GDA2PiWpzaKsok2o+xqbzzaCYoDI8B5KJp/X+GZKDun1V4QancX1VUlfyCvngEN6HizOX8BnxNaoeyWEiVUYCJx061JO/B+aEXdU0lb2v1GK+Su4B0NqOPGNEYCZiAAc0tarNlosLUgG9JYQlteMNVDQsMHUq628OIM1CHdQ0wueDSgn87b8FreVq42U5htHcMElz5l5U0hc3Z+d7W+ucUXnfh+fnKy60yQCkDtLBqQlb0xPWW462iLuQKD5agUVopZZdUopGx2Y85niF5NdbJPXxxsyB6yGWhNWU0wlWi7QlZlVWhrlWD9vilMFpnOSn21lJfr8IFeIKKBrj4F3cEdmckdPSc9u8ke7c6s0lNazlGZHOI7WqFNDBuI2cGi0XFK8Kyn97VKTxe7nnJN529g5gz2NMw+owExgVZo0WiaVHbBV3uKp5MDn5ePOTqfnPkXVEG6uatuJ5XHEJ8PLLnbqXz5HyJfK/PoPFSXZwW74ZZLYjFvtMK8GyVWwBTWrZC818wKKAk5DT47P4c5VZc+w0NcVkIDulBTgh2/42geXQaT51j2/CXK4G0Mdja5+rKyITWpGWFlaM6bOVc/wLoIJkNZYh3nPd794QgVlJBCZY4RfK5uVX6svqn0WBpsZ1tDAWzDdEf0CG1v+7Y3nRWiF6PhrSGsaQ3nho3aSqW1ZIZyOmoOXREYSnKqJaySso522mA7tgJzYhusjBW4oVJT23R2pPRAx+pmKnN67CyhhGKpeoE50wz3jDLMCSUdU70RTcnlpyT7PPAerdAVRA/TXytfOxNDCmOu9l5XByL8mH35EVSL76/O47dF7bNgniBWxS3lfMn83qDtdrv9AwAA//8DAJ9uh65PBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRVTW/jNhD9KwKvlbQSKZGSgD1kHWcbLPKB2OgWvQ3JYcxGIQ2KyjYN/N8LyknX2UUPvdjSSDPz5r031Atx8IhkIGf7/foJXZxITqJdQrSivKj6ohbbqh2abmh52bGu5XX/B8mJ/YLPZCCiUapWPRYMORaNrtpCMqMKrbGvq1pXtG1STbifyPBCwJYan6zC0k+/YZisd2QgX63T/lvqDbZUo591GfyIl26K4FQCc77efNne3BYXgl+vP306vjnhlAqUVpOB6L5TqqOsEIK2RQOoit4AFABMgwbD+74+plkXMTgYy0k/fMegfXQY1UBLysqqoD055ERDhARbwoTb531CstB0nuL5El4uhxfyhIEMNH8j9Fjugxrth8dJznbUH5ZfkpN98HsM0eLCyDLkOEK03mWvPVajRRdJTj6llM2sFE4TGcgFjBO+hdfu3jq8CPCI33x4uD72La/XW5KT2+D/RBVvIe7IQJhRnTZS1dD00kjUPa9awB5MI5vOaM1qZRStTcWFkLQxrNNSAuei1VJSSnJys8luR4jGh8d3gm2OGlz+Dwk2Z6u1Azmi/nGkLYR7jGQgLW8lMtNRLjlo6FlnakDWayUZb7GTQncoBKMMVKuqrla1qFvoRdsKzmjqcjltIjgNo3e4/gvVnAgmA9mGOfW7ArWzDrPLczIQXje66StdNLWEohGmLwAZFLLlfdNJpjkmE1+AHeeAK4h470Ny//X8GZNOK++idbOfp+zSRbwPi5wn491ssrOgdjaiinNIQv3OU8nb4PWsYvbdh3VVViWrWEqa436O2R1qG1DFha9X+DebH1Mop1WVoMwhoItZkj77Faakf62FbhVF1DWTopIG2053baeE5rzjRjWtqljdqqZRzEjdg5Q9U7ShRjYoOMkJJttny6Y1XS8qo6DoIFHGelOAkrLg0vTQiKpjlXjHcHYzpjxphK5oRYseOl40TLKiY5QVTUUrwaBTvIb35j4ZsSt5WTclE91CzRtvrwvTdCQn6xB8WPk5nWLD6d2lMz57yVb+cW9HDNnHLM+upqXPZ3QYYDwNrZ9gnI/reBp9s9C77AD73RLYwvSwXGz0wx1OfnzCMC2Ba4wbfXy2uCX7mNV5tqSvdqhen0C0T7j1flQ7sMceK6/xzMH4PNljpTv42x/Bf92b5f9s2l8vFfPsJu6WybIDyckXDA7HE4dcWRX85E3MXjc3e+eZE8rP7bQf4fkn5n9BwSpTa5mk/Y7+50W+m136gGTLifDNuuKvxejnXj1gyNKigHXprPw342oeo93uAoJGfeU1/lj0kJNHhGkO+Lh8opZT02FYYJzPx2W7dFd2HO2Eyjs9kYFz3pSM0/YV7n++JxpaVqzmh8Ph8A8AAAD//wMADw4oQRAHAAA=
@@ -0,0 +1,18 @@
{
"Microsoft.NET.Workload.Emscripten.Current": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net6": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net7": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net8": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net9": "10.0.111/10.0.100",
"Microsoft.NET.Sdk.Android": "36.1.69/10.0.100",
"Microsoft.NET.Sdk.iOS": "26.5.10315/10.0.100",
"Microsoft.NET.Sdk.MacCatalyst": "26.5.10315/10.0.100",
"Microsoft.NET.Sdk.macOS": "26.5.10315/10.0.100",
"Microsoft.NET.Sdk.Maui": "10.0.20/10.0.100",
"Microsoft.NET.Sdk.tvOS": "26.5.10315/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.Current": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net6": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net7": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net8": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net9": "10.0.111/10.0.100"
}
+149 -67
View File
@@ -17,9 +17,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UomConversion> UomConversions { get; set; } public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; } public DbSet<Item> Items { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; } public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<Sale> Sales { get; set; } public DbSet<ShopPO> ShopPOs { get; set; }
public DbSet<Order> Orders { get; set; } public DbSet<ShopPOItem> ShopPOItems { get; set; }
public DbSet<OrderItem> OrderItems { get; set; } public DbSet<Invoice> Invoices { get; set; }
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; } public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<User> Users { get; set; } public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; } public DbSet<Role> Roles { get; set; }
@@ -36,7 +37,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Supplier> Suppliers { get; set; } public DbSet<Supplier> Suppliers { get; set; }
public DbSet<DriverProfile> DriverProfiles { get; set; } public DbSet<DriverProfile> DriverProfiles { get; set; }
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; } public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
public DbSet<RideCancelRequest> RideCancelRequests { get; set; }
public DbSet<Scheme> Schemes { get; set; } public DbSet<Scheme> Schemes { get; set; }
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; } public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; } public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
@@ -61,11 +61,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; } public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; } public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; } public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
public DbSet<SysConfig> SysConfigs { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<OrderItem>() // --- Composite PKs ---
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
modelBuilder.Entity<RolePermission>() modelBuilder.Entity<RolePermission>()
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId }); .HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
@@ -79,11 +79,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<UserStoreScope>() modelBuilder.Entity<UserStoreScope>()
.HasKey(s => new { s.FK_UserId, s.FK_StoreId }); .HasKey(s => new { s.FK_UserId, s.FK_StoreId });
modelBuilder.Entity<User>() modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(u => u.ReportsTo) .HasKey(lo => new { lo.FK_LoadingSheetId, lo.FK_ShopPOId });
.WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId) // --- Unique indexes ---
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<StockBalance>() modelBuilder.Entity<StockBalance>()
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId }) .HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
@@ -93,14 +92,20 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId }) .HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique(); .IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>() modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId) .HasIndex(dp => dp.FK_UserId)
.IsUnique(); .IsUnique();
// --- User ---
modelBuilder.Entity<User>()
.HasOne(u => u.ReportsTo)
.WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId)
.OnDelete(DeleteBehavior.Restrict);
// --- Vehicle ---
modelBuilder.Entity<Vehicle>() modelBuilder.Entity<Vehicle>()
.HasOne<Warehouse>() .HasOne<Warehouse>()
.WithMany() .WithMany()
@@ -108,11 +113,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(false); .IsRequired(false);
modelBuilder.Entity<DeliveryRun>() // --- DeliveryRun ---
.HasOne(r => r.SourceWarehouse)
.WithMany()
.HasForeignKey(r => r.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryRun>() modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.DriverProfile) .HasOne(r => r.DriverProfile)
@@ -120,30 +121,97 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId) .HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>() // --- ShopPO ---
.HasOne(s => s.Store)
modelBuilder.Entity<ShopPO>()
.HasOne(p => p.Store)
.WithMany() .WithMany()
.HasForeignKey(s => s.FK_StoreId) .HasForeignKey(p => p.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>() // InvoiceId is a plain column — no FK constraint
.HasOne(s => s.CreatedByUser) modelBuilder.Entity<ShopPO>()
.WithMany() .Property(p => p.InvoiceId)
.HasForeignKey(s => s.CreatedByUserId) .HasColumnName("InvoiceId");
// --- ShopPOItem ---
modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.ShopPO)
.WithMany(p => p.Items)
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>() modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
// --- Invoice ---
modelBuilder.Entity<Invoice>()
.HasOne(i => i.ShopPO)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasMany(i => i.Payments)
.WithOne(p => p.Invoice)
.HasForeignKey(p => p.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheet ---
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.Ride) .HasOne(s => s.Ride)
.WithMany() .WithMany()
.HasForeignKey(s => s.FK_RideId) .HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheetOrder ---
modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(lo => lo.LoadingSheet)
.WithMany(s => s.Orders)
.HasForeignKey(lo => lo.FK_LoadingSheetId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(lo => lo.ShopPO)
.WithMany()
.HasForeignKey(lo => lo.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheetItem ---
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.LoadingSheet)
.WithMany(s => s.Items)
.HasForeignKey(i => i.FK_LoadingSheetId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Warehouse)
.WithMany()
.HasForeignKey(i => i.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false); .IsRequired(false);
modelBuilder.Entity<Order>() // --- SchemeRedemption ---
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>() modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme) .HasOne(r => r.Scheme)
@@ -152,10 +220,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>() modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Order) .HasOne(r => r.ShopPO)
.WithMany() .WithMany()
.HasForeignKey(r => r.FK_OrderId) .HasForeignKey(r => r.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SchemeRedemption>() modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.FreeItem) .HasOne(r => r.FreeItem)
@@ -163,6 +232,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_FreeItemId) .HasForeignKey(r => r.FK_FreeItemId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// --- Scheme ---
modelBuilder.Entity<Scheme>() modelBuilder.Entity<Scheme>()
.HasOne(s => s.TriggerItem) .HasOne(s => s.TriggerItem)
.WithMany() .WithMany()
@@ -191,23 +262,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId }) .HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
.IsUnique(); .IsUnique();
modelBuilder.Entity<LoadingSheet>() // --- PurchaseOrder ---
.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>() modelBuilder.Entity<PurchaseOrder>()
.HasOne(po => po.Supplier) .HasOne(po => po.Supplier)
@@ -227,6 +282,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId) .HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
// --- GoodsReceipt ---
modelBuilder.Entity<GoodsReceipt>() modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.PurchaseOrder) .HasOne(gr => gr.PurchaseOrder)
.WithMany() .WithMany()
@@ -251,6 +311,14 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_PurchaseOrderItemId) .HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
// --- PurchaseReturn ---
modelBuilder.Entity<PurchaseReturn>() modelBuilder.Entity<PurchaseReturn>()
.HasOne(pr => pr.PurchaseOrder) .HasOne(pr => pr.PurchaseOrder)
.WithMany() .WithMany()
@@ -269,22 +337,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId) .HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict); .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>() modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId); .HasIndex(i => i.FK_PurchaseReturnId);
// --- SaleReturn ---
modelBuilder.Entity<SaleReturn>() modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.Order) .HasOne(r => r.ShopPO)
.WithMany() .WithMany()
.HasForeignKey(r => r.FK_OrderId) .HasForeignKey(r => r.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturn>() modelBuilder.Entity<SaleReturn>()
@@ -308,12 +369,16 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SaleReturnItem>() modelBuilder.Entity<SaleReturnItem>()
.HasIndex(i => i.FK_SaleReturnId); .HasIndex(i => i.FK_SaleReturnId);
// --- CreditNote ---
modelBuilder.Entity<CreditNote>() modelBuilder.Entity<CreditNote>()
.HasOne(n => n.SaleReturn) .HasOne(n => n.SaleReturn)
.WithMany() .WithMany()
.HasForeignKey(n => n.FK_SaleReturnId) .HasForeignKey(n => n.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// --- Claims ---
modelBuilder.Entity<Claim>() modelBuilder.Entity<Claim>()
.HasOne(c => c.AgencyUser) .HasOne(c => c.AgencyUser)
.WithMany() .WithMany()
@@ -366,6 +431,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SupplierClaimItem>() modelBuilder.Entity<SupplierClaimItem>()
.HasIndex(i => i.FK_SupplierClaimId); .HasIndex(i => i.FK_SupplierClaimId);
// --- DeliveryTask ---
modelBuilder.Entity<DeliveryTask>() modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride) .HasOne(t => t.Ride)
.WithMany() .WithMany()
@@ -373,11 +440,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>() modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Order) .HasOne(t => t.ShopPO)
.WithMany() .WithMany()
.HasForeignKey(t => t.FK_OrderId) .HasForeignKey(t => t.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// --- Regional ---
modelBuilder.Entity<RegionalTarget>() modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period }) .HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique(); .IsUnique();
@@ -400,6 +469,19 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(r => r.FK_RegionId) .HasIndex(r => r.FK_RegionId)
.IsUnique(); .IsUnique();
// --- SysConfig ---
modelBuilder.Entity<SysConfig>()
.HasKey(c => c.Key);
modelBuilder.Entity<SysConfig>()
.Property(c => c.Key)
.HasMaxLength(100);
modelBuilder.Entity<SysConfig>().HasData(
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null }
);
Seed.RoleAndPermissionSeed.Apply(modelBuilder); Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder); Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder); Seed.OperationsDataSeed.Apply(modelBuilder);
+9 -3
View File
@@ -16,6 +16,9 @@ public static class CommerceSeed
private static readonly Guid SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001"); private static readonly Guid SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001");
private static readonly Guid Payment1Id = new("47000000-0000-0000-0000-000000000001"); private static readonly Guid Payment1Id = new("47000000-0000-0000-0000-000000000001");
private static readonly Guid Payment2Id = new("47000000-0000-0000-0000-000000000002");
private static readonly Guid Payment3Id = new("47000000-0000-0000-0000-000000000003");
private static readonly Guid Payment4Id = new("47000000-0000-0000-0000-000000000004");
private static readonly Guid SaleReturn1Id = new("51000000-0000-0000-0000-000000000001"); private static readonly Guid SaleReturn1Id = new("51000000-0000-0000-0000-000000000001");
private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001"); private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001");
@@ -80,15 +83,18 @@ public static class CommerceSeed
); );
modelBuilder.Entity<SchemeRedemption>().HasData( modelBuilder.Entity<SchemeRedemption>().HasData(
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_OrderId = OperationsDataSeed.Order1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) } new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
); );
modelBuilder.Entity<Payment>().HasData( modelBuilder.Entity<Payment>().HasData(
new Payment { Id = Payment1Id, FK_SaleId = OperationsDataSeed.Sale1Id, FK_StoreId = MasterDataSeed.FortStoreId, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true } new Payment { Id = Payment1Id, FK_InvoiceId = OperationsDataSeed.Invoice1Id, Amount = 3000m, Method = PaymentMethod.Cash, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true },
new Payment { Id = Payment2Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 1000m, Method = PaymentMethod.Cash, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 31, 9, 20, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, CreatedAt = new DateTime(2026, 8, 31, 9, 20, 0), IsActive = true },
new Payment { Id = Payment3Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 800m, Method = PaymentMethod.Cheque, EntryMode = EntryMode.DayEndManual, ChequeNumber = "000456", ChequeDate = new DateTime(2026, 8, 31), CollectedAt = new DateTime(2026, 8, 31, 11, 0, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, RecordedBy = MasterDataSeed.Supervisor1UserId, CreatedAt = new DateTime(2026, 8, 31, 18, 0, 0), IsActive = true },
new Payment { Id = Payment4Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 300m, Method = PaymentMethod.Credit, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 31, 11, 5, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, CreatedAt = new DateTime(2026, 8, 31, 11, 5, 0), IsActive = true }
); );
modelBuilder.Entity<SaleReturn>().HasData( modelBuilder.Entity<SaleReturn>().HasData(
new SaleReturn { Id = SaleReturn1Id, FK_OrderId = OperationsDataSeed.Order1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true } new SaleReturn { Id = SaleReturn1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
); );
modelBuilder.Entity<SaleReturnItem>().HasData( modelBuilder.Entity<SaleReturnItem>().HasData(
+87 -72
View File
@@ -8,15 +8,20 @@ public static class OperationsDataSeed
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001"); internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002"); internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
// Kept for OperationsExtrasSeed stock movement reference (ReferenceId on MovementType.Sale)
internal static readonly Guid Sale1Id = new("32000000-0000-0000-0000-000000000001"); internal static readonly Guid Sale1Id = new("32000000-0000-0000-0000-000000000001");
internal static readonly Guid Sale2Id = new("32000000-0000-0000-0000-000000000002");
internal static readonly Guid Order1Id = new("33000000-0000-0000-0000-000000000001"); internal static readonly Guid ShopPO1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid Order2Id = new("33000000-0000-0000-0000-000000000002"); internal static readonly Guid ShopPO2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPO3Id = new("33000000-0000-0000-0000-000000000003");
internal static readonly Guid OrderItem1Id = new("34000000-0000-0000-0000-000000000001"); internal static readonly Guid ShopPOItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002"); internal static readonly Guid ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid OrderItem3Id = new("34000000-0000-0000-0000-000000000003"); internal static readonly Guid ShopPOItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid ShopPOItem4Id = new("34000000-0000-0000-0000-000000000004");
internal static readonly Guid Invoice1Id = new("34500000-0000-0000-0000-000000000001");
internal static readonly Guid Invoice2Id = new("34500000-0000-0000-0000-000000000002");
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001"); internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
@@ -31,16 +36,12 @@ public static class OperationsDataSeed
new DeliveryRun new DeliveryRun
{ {
Id = ClosedSaleRunId, Id = ClosedSaleRunId,
RideType = RideType.Sale,
Status = RideStatus.Closed, Status = RideStatus.Closed,
FK_VehicleId = MasterDataSeed.VanVehicleId, FK_VehicleId = MasterDataSeed.VanVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId, FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
FK_RouteId = MasterDataSeed.ColomboRouteId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Meterstart = 10500, Meterstart = 10500,
Meterend = 10550, Meterend = 10550,
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0), StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
ActivatedAt = new DateTime(2026, 8, 25, 8, 15, 0),
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0), ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true IsActive = true
@@ -48,86 +49,101 @@ public static class OperationsDataSeed
new DeliveryRun new DeliveryRun
{ {
Id = ActiveDeliveryRunId, Id = ActiveDeliveryRunId,
RideType = RideType.Delivery, Status = RideStatus.Open,
Status = RideStatus.Active,
FK_VehicleId = MasterDataSeed.TruckVehicleId, FK_VehicleId = MasterDataSeed.TruckVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId, FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
FK_RouteId = MasterDataSeed.KandyRouteId,
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
Meterstart = 20300, Meterstart = 20300,
Meterend = 0, Meterend = 0,
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0), StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
ClosedAt = null, ClosedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0), CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
IsActive = true IsActive = true
} }
); );
modelBuilder.Entity<Sale>().HasData( modelBuilder.Entity<ShopPO>().HasData(
new Sale new ShopPO
{ {
Id = Sale1Id, Id = ShopPO1Id,
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId, FK_StoreId = MasterDataSeed.FortStoreId,
CreatedByUserId = MasterDataSeed.SalesRep1UserId, RaisedById = MasterDataSeed.SalesRep1UserId,
FK_RideId = ClosedSaleRunId, InvoiceId = Invoice1Id,
TotalAmount = 3000m, TotalAmount = 3000m,
Status = SaleStatus.Completed, Status = ShopPOStatus.Fulfilled,
PaymentStatus = PaymentStatus.Paid, ConfirmedAt = new DateTime(2026, 8, 25, 8, 30, 0),
FulfillImmediately = false, ConfirmedBy = MasterDataSeed.Supervisor1UserId,
CompletedAt = new DateTime(2026, 8, 25, 9, 30, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new Sale
{
Id = Sale2Id,
SaleDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
FK_RideId = ActiveDeliveryRunId,
TotalAmount = 1200m,
Status = SaleStatus.Pending,
PaymentStatus = PaymentStatus.Pending,
FulfillImmediately = false,
CompletedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<Order>().HasData(
new Order
{
Id = Order1Id,
FK_SaleId = Sale1Id,
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
TotalAmount = 3000m,
Status = OrderStatus.Fulfilled,
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0), FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true IsActive = true
}, },
new Order new ShopPO
{ {
Id = Order2Id, Id = ShopPO2Id,
FK_SaleId = Sale2Id,
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId, FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId,
InvoiceId = null,
TotalAmount = 1200m, TotalAmount = 1200m,
Status = OrderStatus.Pending, Status = ShopPOStatus.Pending,
ConfirmedAt = null,
ConfirmedBy = null,
FulfilledAt = null, FulfilledAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true IsActive = true
},
new ShopPO
{
Id = ShopPO3Id,
FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId,
InvoiceId = Invoice2Id,
TotalAmount = 2400m,
Status = ShopPOStatus.Fulfilled,
ConfirmedAt = new DateTime(2026, 8, 31, 8, 30, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
FulfilledAt = new DateTime(2026, 8, 31, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0),
IsActive = true
} }
); );
modelBuilder.Entity<OrderItem>().HasData( modelBuilder.Entity<ShopPOItem>().HasData(
new OrderItem { Id = OrderItem1Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true }, new ShopPOItem { Id = ShopPOItem1Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem2Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true }, new ShopPOItem { Id = ShopPOItem2Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem3Id, FK_OrderId = Order2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true } new ShopPOItem { Id = ShopPOItem3Id, FK_ShopPOId = ShopPO2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem4Id, FK_ShopPOId = ShopPO3Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 20, QuantityFulfilled = 20, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0), IsActive = true }
);
modelBuilder.Entity<Invoice>().HasData(
new Invoice
{
Id = Invoice1Id,
FK_ShopPOId = ShopPO1Id,
Status = InvoiceStatus.Finalized,
PaymentStatus = PaymentStatus.Paid,
CreatedBy = MasterDataSeed.SalesRep1UserId,
PrintedAt = new DateTime(2026, 8, 25, 8, 45, 0),
PrintedBy = MasterDataSeed.SalesRep1UserId,
FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0),
FinalizedBy = MasterDataSeed.Supervisor1UserId,
TotalAmount = 3000m,
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0),
IsActive = true
},
new Invoice
{
Id = Invoice2Id,
FK_ShopPOId = ShopPO3Id,
Status = InvoiceStatus.Printed,
PaymentStatus = PaymentStatus.PartiallyPaid,
CreatedBy = MasterDataSeed.SalesRep2UserId,
PrintedAt = new DateTime(2026, 8, 31, 9, 15, 0),
PrintedBy = MasterDataSeed.SalesRep2UserId,
FinalizedAt = null,
FinalizedBy = null,
TotalAmount = 2400m,
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0),
IsActive = true
}
); );
modelBuilder.Entity<LoadingSheet>().HasData( modelBuilder.Entity<LoadingSheet>().HasData(
@@ -135,20 +151,19 @@ public static class OperationsDataSeed
{ {
Id = LoadingSheet1Id, Id = LoadingSheet1Id,
FK_RideId = ClosedSaleRunId, FK_RideId = ClosedSaleRunId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId, Status = LoadingSheetStatus.Confirmed,
Status = LoadingSheetStatus.ReceiptConfirmed, PopulatedBy = MasterDataSeed.Supervisor1UserId,
DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0), PopulatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId, ConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0), ConfirmedBy = MasterDataSeed.Supervisor1UserId,
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true IsActive = true
} }
); );
modelBuilder.Entity<LoadingSheetItem>().HasData( modelBuilder.Entity<LoadingSheetItem>().HasData(
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, QuantityDispatched = 50m, QuantityReceived = 50m, QuantityResolved = null }, new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 50m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null } new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 30m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true }
); );
modelBuilder.Entity<DeliveryTask>().HasData( modelBuilder.Entity<DeliveryTask>().HasData(
@@ -156,7 +171,7 @@ public static class OperationsDataSeed
{ {
Id = DeliveryTask1Id, Id = DeliveryTask1Id,
FK_RideId = ActiveDeliveryRunId, FK_RideId = ActiveDeliveryRunId,
FK_OrderId = Order2Id, FK_ShopPOId = ShopPO2Id,
Sequence = 1, Sequence = 1,
Status = DeliveryTaskStatus.Pending, Status = DeliveryTaskStatus.Pending,
CustomerConfirmed = false, CustomerConfirmed = false,
@@ -32,8 +32,6 @@ public static class OperationsExtrasSeed
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001"); private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
private static readonly Guid RideCancelRequest1Id = new("65000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001"); private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002"); private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002");
@@ -95,21 +93,6 @@ public static class OperationsExtrasSeed
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId } new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
); );
modelBuilder.Entity<RideCancelRequest>().HasData(
new RideCancelRequest
{
Id = RideCancelRequest1Id,
FK_RideId = OperationsDataSeed.ActiveDeliveryRunId,
TypeCode = CancelRequestType.VehicleBreakdown,
Reason = "Reported minor engine issue, resolved after inspection",
RequestedBy = MasterDataSeed.Driver2UserId,
RequestedAt = new DateTime(2026, 8, 30, 9, 10, 0),
Status = CancelRequestStatus.Rejected,
ApprovedBy = MasterDataSeed.Supervisor1UserId,
ApprovedAt = new DateTime(2026, 8, 30, 9, 15, 0)
}
);
modelBuilder.Entity<RouteVisitLog>().HasData( modelBuilder.Entity<RouteVisitLog>().HasData(
new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true }, new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true } new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true }
@@ -1,4 +1,4 @@
using Distribution.Models; using Distribution.Models;
namespace Distribution.Features.DeliveryRide; namespace Distribution.Features.DeliveryRide;
@@ -7,7 +7,7 @@ public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantit
public record DeliveryTaskResponse( public record DeliveryTaskResponse(
Guid Id, Guid Id,
Guid FK_RideId, Guid FK_RideId,
Guid FK_OrderId, Guid FK_ShopPOId,
Guid StoreId, Guid StoreId,
string StoreName, string StoreName,
int Sequence, int Sequence,
@@ -1,4 +1,4 @@
using Distribution.Data; using Distribution.Data;
using Distribution.Models; using Distribution.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -8,27 +8,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
{ {
public async Task<DeliveryTask?> GetByIdAsync(Guid id) => public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks await context.DeliveryTasks
.Include(t => t.Order) .Include(t => t.ShopPO)
.ThenInclude(o => o.Store) .ThenInclude(o => o.Store)
.Include(t => t.Order) .Include(t => t.ShopPO)
.ThenInclude(o => o.OrderItems) .ThenInclude(o => o.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item) .ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id); .FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) => public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks await context.DeliveryTasks
.Include(t => t.Order) .Include(t => t.ShopPO)
.ThenInclude(o => o.Store) .ThenInclude(o => o.Store)
.Include(t => t.Order) .Include(t => t.ShopPO)
.ThenInclude(o => o.OrderItems) .ThenInclude(o => o.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item) .ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId) .Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence) .OrderBy(t => t.Sequence)
.ToListAsync(); .ToListAsync();
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) => public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
await context.DeliveryTasks await context.DeliveryTasks
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId); .AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) => public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task); await context.DeliveryTasks.AddAsync(task);
@@ -1,6 +1,5 @@
using Distribution.Features.Order;
using Distribution.Features.Ride; using Distribution.Features.Ride;
using Distribution.Features.Sale; using Distribution.Features.ShopPO;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -10,23 +9,22 @@ namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService( public class DeliveryTaskService(
IDeliveryTaskRepository repo, IDeliveryTaskRepository repo,
IRideRepository rideRepo, IRideRepository rideRepo,
IOrderRepository orderRepo, IShopPORepository shopPORepo,
ISaleRepository saleRepo,
IStockRepository stockRepo, IStockRepository stockRepo,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private static DeliveryTaskResponse ToResponse(DeliveryTask t) => private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id, new(t.Id,
t.FK_RideId, t.FK_RideId,
t.FK_OrderId, t.FK_ShopPOId,
t.Order.FK_StoreId, t.ShopPO.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty, t.ShopPO.Store?.StoreName ?? string.Empty,
t.Sequence, t.Sequence,
t.Status, t.Status,
t.CustomerConfirmed, t.CustomerConfirmed,
t.ConfirmedAt, t.ConfirmedAt,
t.Note, t.Note,
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse( t.ShopPO.Items.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId, i.FK_ItemId,
i.Item?.Name ?? string.Empty, i.Item?.Name ?? string.Empty,
i.Quantity, i.Quantity,
@@ -34,52 +32,8 @@ public class DeliveryTaskService(
i.UnitPrice)).ToList(), i.UnitPrice)).ToList(),
t.CreatedAt); t.CreatedAt);
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default) public Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default) =>
{ throw new NotImplementedException("Delivery task auto-generation is deferred — route-based dispatch not yet implemented.");
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
if (ride.RideType != RideType.Delivery)
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var created = new List<DeliveryTask>();
int sequence = 1;
foreach (var order in orders)
{
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
continue;
var task = new DeliveryTask
{
FK_RideId = rideId,
FK_OrderId = order.Id,
Sequence = sequence++,
Status = DeliveryTaskStatus.Pending
};
created.Add(task);
}
if (created.Count == 0)
return new List<DeliveryTaskResponse>();
await uow.BeginAsync(ct);
try
{
foreach (var task in created)
await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList();
}
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId) public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
{ {
@@ -103,16 +57,16 @@ public class DeliveryTaskService(
foreach (var input in request.Items) foreach (var input in request.Items)
{ {
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId) var shopPOItem = task.ShopPO.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order"); ?? throw new InvalidOperationException($"Item {input.ItemId} is not on this ShopPO");
if (input.QuantityFulfilled < 0) if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}"); throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > orderItem.Quantity) if (input.QuantityFulfilled > shopPOItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}"); throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
orderItem.QuantityFulfilled = input.QuantityFulfilled; shopPOItem.QuantityFulfilled = input.QuantityFulfilled;
} }
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
@@ -135,7 +89,7 @@ public class DeliveryTaskService(
if (task.Status != DeliveryTaskStatus.Pending) if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed"); throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0)) if (!task.ShopPO.Items.Any(i => i.QuantityFulfilled > 0))
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero"); throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
var ride = await rideRepo.GetByIdAsync(task.FK_RideId) var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
@@ -143,21 +97,21 @@ public class DeliveryTaskService(
var vehicleId = ride.FK_VehicleId; var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity); bool fullyDelivered = task.ShopPO.Items.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true; task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now; task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered; task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var order = task.Order; var shopPO = task.ShopPO;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled; shopPO.Status = fullyDelivered ? ShopPOStatus.Fulfilled : ShopPOStatus.PartiallyFulfilled;
if (fullyDelivered) if (fullyDelivered)
order.FulfilledAt = DateTime.Now; shopPO.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0)) foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
{ {
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId); var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
var availableQuantity = vehicleBalance?.Quantity ?? 0; var availableQuantity = vehicleBalance?.Quantity ?? 0;
@@ -180,21 +134,12 @@ public class DeliveryTaskService(
ToLocationType = LocationType.External, ToLocationType = LocationType.External,
Quantity = item.QuantityFulfilled, Quantity = item.QuantityFulfilled,
Type = MovementType.Sale, Type = MovementType.Sale,
ReferenceId = task.FK_OrderId ReferenceId = task.FK_ShopPOId
}); });
} }
repo.Update(task); repo.Update(task);
orderRepo.Update(order); shopPORepo.Update(shopPO);
if (fullyDelivered)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
@@ -0,0 +1,13 @@
using Distribution.Models;
namespace Distribution.Features.Invoice;
public interface IInvoiceRepository
{
Task<Models.Invoice?> GetByIdAsync(Guid id);
Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId);
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status);
Task AddAsync(Models.Invoice invoice);
void Update(Models.Invoice invoice);
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Models;
namespace Distribution.Features.Invoice;
[ApiController]
[Route("api/invoices")]
public class InvoiceController(InvoiceService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? agentId,
[FromQuery] Guid? routeId,
[FromQuery] Guid? shopId,
[FromQuery] InvoiceStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,22 @@
using Distribution.Models;
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId);
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment> Adjustments);
public record InvoiceResponse(
Guid Id,
Guid ShopPOId,
string StoreName,
InvoiceStatus Status,
PaymentStatus PaymentStatus,
Guid CreatedBy,
DateTime? PrintedAt,
Guid? PrintedBy,
DateTime? FinalizedAt,
Guid? FinalizedBy,
decimal TotalAmount,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,47 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Invoice;
public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
{
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Include(i => i.Payments.Where(p => p.IsActive))
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
public async Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId) =>
await context.Invoices
.Include(i => i.ShopPO)
.FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive);
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Where(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status))
.OrderByDescending(i => i.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.CountAsync(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status));
public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice);
public void Update(Models.Invoice invoice) =>
context.Invoices.Update(invoice);
}
@@ -0,0 +1,33 @@
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static InvoiceResponse ToResponse(Models.Invoice i) =>
new(i.Id, i.FK_ShopPOId,
i.ShopPO?.Store?.StoreName ?? string.Empty,
i.Status, i.PaymentStatus, i.CreatedBy,
i.PrintedAt, i.PrintedBy,
i.FinalizedAt, i.FinalizedBy,
i.TotalAmount, i.IsActive, i.CreatedAt);
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
var invoice = await repo.GetByIdAsync(id);
return invoice is null ? null : ToResponse(invoice);
}
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, status);
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
}
@@ -8,7 +8,6 @@ public interface ILoadingSheetRepository
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId); Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize); Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status); Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
Task AddAsync(LoadingSheet sheet); Task AddAsync(LoadingSheet sheet);
void Update(LoadingSheet sheet); void Update(LoadingSheet sheet);
void RemoveItems(IEnumerable<LoadingSheetItem> items); void RemoveItems(IEnumerable<LoadingSheetItem> items);
@@ -1,4 +1,4 @@
using Distribution.Models; using Distribution.Models;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -29,9 +29,10 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
[HttpPost] [HttpPost]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct) public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{ {
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try try
{ {
var result = await service.CreateAsync(request, ct); var result = await service.CreateAsync(request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
@@ -50,90 +51,13 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
} }
[HttpPost("{id:guid}/confirm-dispatch")] [HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct) public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{ {
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try try
{ {
var result = await service.ConfirmDispatchAsync(id, userId, ct); var result = await service.ConfirmAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/receipt-items")]
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")]
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")]
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 }); }
}
[HttpPatch("{id:guid}/dispatch")]
public async Task<IActionResult> PatchDispatch(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 }); }
}
[HttpPatch("{id:guid}/receipt")]
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/resolve")]
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchResolveAsync(id, userId, ct);
return Ok(result); return Ok(result);
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
@@ -5,37 +5,27 @@ namespace Distribution.Features.LoadingEvent;
public record LoadingSheetResponse( public record LoadingSheetResponse(
Guid Id, Guid Id,
Guid FK_RideId, Guid FK_RideId,
Guid FK_SourceWarehouseId,
string WarehouseName,
LoadingSheetStatus Status, LoadingSheetStatus Status,
DateTime? DispatchConfirmedAt, Guid PopulatedBy,
Guid? DispatchConfirmedBy, DateTime PopulatedAt,
DateTime? ReceiptConfirmedAt, DateTime? ConfirmedAt,
Guid? ReceiptConfirmedBy, Guid? ConfirmedBy,
DateTime CreatedAt, DateTime CreatedAt,
List<LoadingSheetItemResponse> Items); List<LoadingSheetItemResponse> Items,
List<LoadingSheetOrderResponse> Orders);
public record LoadingSheetItemResponse( public record LoadingSheetItemResponse(
Guid Id, Guid Id,
Guid FK_ItemId, Guid FK_ItemId,
string ItemName, string ItemName,
Guid FK_WarehouseId,
string WarehouseName,
decimal QuantityDispatched, decimal QuantityDispatched,
decimal? QuantityReceived, Guid? FK_ShopPOItemId);
decimal? QuantityResolved);
public record LoadingSheetOrderResponse(Guid ShopPOId);
public record CreateLoadingSheetRequest(Guid FK_RideId); public record CreateLoadingSheetRequest(Guid FK_RideId);
public record SetItemsRequest(List<SetItemInput> Items); public record SetItemsRequest(List<SetItemInput> Items);
public record SetItemInput(Guid FK_ItemId, decimal Quantity); public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
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);
// Combined PATCH endpoints used by the frontend
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
@@ -8,20 +8,20 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
{ {
public async Task<LoadingSheet?> GetByIdAsync(Guid id) => public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
await context.LoadingSheets await context.LoadingSheets
.Include(s => s.SourceWarehouse)
.Include(s => s.Items).ThenInclude(i => i.Item) .Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Orders)
.FirstOrDefaultAsync(s => s.Id == id); .FirstOrDefaultAsync(s => s.Id == id);
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) => public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
await context.LoadingSheets await context.LoadingSheets
.FirstOrDefaultAsync(s => .FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
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) => public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
await context.LoadingSheets await context.LoadingSheets
.Include(s => s.SourceWarehouse) .Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Orders)
.Where(s => .Where(s =>
(rideId == null || s.FK_RideId == rideId) && (rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status)) (status == null || s.Status == status))
@@ -36,19 +36,6 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
(rideId == null || s.FK_RideId == rideId) && (rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status)); (status == null || s.Status == status));
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
{
var groups = await context.OrderItems
.Where(oi => oi.IsActive
&& oi.Order.IsActive
&& oi.Order.Store.FK_RouteId == routeId
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
.ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
public async Task AddAsync(LoadingSheet sheet) => public async Task AddAsync(LoadingSheet sheet) =>
await context.LoadingSheets.AddAsync(sheet); await context.LoadingSheets.AddAsync(sheet);
@@ -24,7 +24,7 @@ public class LoadingSheetService(
return sheet is null ? null : ToResponse(sheet); return sheet is null ? null : ToResponse(sheet);
} }
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, CancellationToken ct = default) public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, Guid userId, CancellationToken ct = default)
{ {
var ride = await rideRepo.GetByIdAsync(request.FK_RideId) var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
?? throw new KeyNotFoundException("Ride not found"); ?? throw new KeyNotFoundException("Ride not found");
@@ -34,22 +34,16 @@ public class LoadingSheetService(
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId); var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
if (existing is not null) if (existing is not null)
throw new InvalidOperationException("This ride already has an active loading sheet"); throw new InvalidOperationException("This ride already has a Planned loading sheet");
var sheet = new LoadingSheet var sheet = new LoadingSheet
{ {
FK_RideId = ride.Id, FK_RideId = ride.Id,
FK_SourceWarehouseId = ride.FK_SourceWarehouseId, Status = LoadingSheetStatus.Planned,
Status = LoadingSheetStatus.Pending PopulatedBy = userId,
PopulatedAt = DateTime.Now
}; };
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); await uow.BeginAsync(ct);
try try
{ {
@@ -67,8 +61,8 @@ public class LoadingSheetService(
var sheet = await repo.GetByIdAsync(id) var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found"); ?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Pending) if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException("Items can only be changed while the sheet is Pending"); throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
@@ -78,6 +72,8 @@ public class LoadingSheetService(
{ {
FK_LoadingSheetId = sheet.Id, FK_LoadingSheetId = sheet.Id,
FK_ItemId = i.FK_ItemId, FK_ItemId = i.FK_ItemId,
FK_WarehouseId = i.FK_WarehouseId,
FK_ShopPOItemId = i.FK_ShopPOItemId,
QuantityDispatched = i.Quantity QuantityDispatched = i.Quantity
})); }));
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
@@ -88,289 +84,91 @@ public class LoadingSheetService(
return ToResponse(saved!); return ToResponse(saved!);
} }
public async Task<LoadingSheetResponse> ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default) public async Task<LoadingSheetResponse> ConfirmAsync(Guid id, Guid userId, CancellationToken ct = default)
{ {
var sheet = await repo.GetByIdAsync(id) var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found"); ?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Pending) if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status"); throw new InvalidOperationException($"Cannot confirm a sheet in {sheet.Status} status");
if (!sheet.Items.Any(i => i.QuantityDispatched > 0)) if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero"); 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) var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found"); ?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
if (hasDiscrepancy) await ApplyLoadMovementsAsync(sheet.Id, ride.FK_VehicleId, sheet.Items, userId);
{
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(); sheet.Status = LoadingSheetStatus.Confirmed;
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId); sheet.ConfirmedAt = DateTime.Now;
sheet.ConfirmedBy = userId;
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
}
repo.Update(sheet); repo.Update(sheet);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet); var saved = await repo.GetByIdAsync(id);
return ToResponse(saved!);
} }
public async Task<LoadingSheetResponse> ResolveAsync(Guid id, ResolveDiscrepancyRequest request, Guid userId, CancellationToken ct = default) private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> items, Guid userId)
{ {
var sheet = await repo.GetByIdAsync(id) foreach (var item in items.Where(i => i.QuantityDispatched > 0))
?? 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) var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet"); ?? throw new InvalidOperationException($"No stock found in warehouse for item {item.FK_ItemId}");
if (input.QuantityResolved < 0)
throw new InvalidOperationException("Resolved quantity cannot be negative");
item.QuantityResolved = input.QuantityResolved;
}
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue)) if (warehouseBalance.Quantity < item.QuantityDispatched)
throw new InvalidOperationException("All items must have a resolved quantity"); throw new InvalidOperationException($"Insufficient warehouse stock for item {item.FK_ItemId}");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId) warehouseBalance.Quantity -= item.QuantityDispatched;
?? 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);
}
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, 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 set receipt for a sheet in {sheet.Status} status");
foreach (var input in items)
{
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
sheetItem.QuantityReceived = input.QuantityReceived;
}
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 = 0;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, 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 item in sheet.Items)
{
if (!item.QuantityReceived.HasValue)
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
item.QuantityResolved = item.QuantityReceived.Value;
}
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 = 0;
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; warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance); stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId); var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
if (vehicleBalance is null) if (vehicleBalance is null)
{ {
await stockRepo.AddBalanceAsync(new StockBalance await stockRepo.AddBalanceAsync(new StockBalance
{ {
FK_ItemId = itemId, FK_ItemId = item.FK_ItemId,
LocationId = vehicleId, LocationId = vehicleId,
LocationType = LocationType.Vehicle, LocationType = LocationType.Vehicle,
Quantity = qty Quantity = item.QuantityDispatched
}); });
} }
else else
{ {
vehicleBalance.Quantity += qty; vehicleBalance.Quantity += item.QuantityDispatched;
vehicleBalance.UpdatedAt = DateTime.Now; vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance); stockRepo.UpdateBalance(vehicleBalance);
} }
await stockRepo.AddMovementAsync(new StockMovement await stockRepo.AddMovementAsync(new StockMovement
{ {
FK_ItemId = itemId, FK_ItemId = item.FK_ItemId,
FromLocationId = sheet.FK_SourceWarehouseId, FromLocationId = item.FK_WarehouseId,
FromLocationType = LocationType.Warehouse, FromLocationType = LocationType.Warehouse,
ToLocationId = vehicleId, ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle, ToLocationType = LocationType.Vehicle,
Quantity = qty, Quantity = item.QuantityDispatched,
Type = MovementType.Load, Type = MovementType.Load,
ReferenceId = sheet.Id, ReferenceId = sheetId,
CreatedBy = userId CreatedBy = userId
}); });
} }
} }
private static LoadingSheetResponse ToResponse(LoadingSheet s) => private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId, new(s.Id, s.FK_RideId, s.Status, s.PopulatedBy, s.PopulatedAt,
s.SourceWarehouse?.Name ?? string.Empty, s.ConfirmedAt, s.ConfirmedBy, s.CreatedAt,
s.Status,
s.DispatchConfirmedAt, s.DispatchConfirmedBy,
s.ReceiptConfirmedAt, s.ReceiptConfirmedBy,
s.CreatedAt,
s.Items.Select(i => new LoadingSheetItemResponse( s.Items.Select(i => new LoadingSheetItemResponse(
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList()); i.FK_WarehouseId, i.Warehouse?.Name ?? string.Empty,
i.QuantityDispatched, i.FK_ShopPOItemId)).ToList(),
s.Orders.Select(o => new LoadingSheetOrderResponse(o.FK_ShopPOId)).ToList());
} }
@@ -1,15 +0,0 @@
namespace Distribution.Features.Order;
public interface IOrderRepository
{
Task<Models.Order?> GetByIdAsync(Guid id);
Task<List<Models.Order>> GetAllAsync();
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
}
@@ -1,55 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Order;
[ApiController]
[Route("api/orders")]
public class OrderController(OrderService 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);
}
[HttpGet("store/{storeId:guid}")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : 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, UpdateOrderRequest 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();
}
}
-21
View File
@@ -1,21 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Order;
public record CreateOrderRequest(Guid SaleId);
public record UpdateOrderRequest(OrderStatus Status);
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid SaleId,
DateTime OrderDate,
Guid StoreId,
decimal TotalAmount,
OrderStatus Status,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -1,49 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Order;
public class OrderRepository(AppDBContext context) : IOrderRepository
{
public async Task<Models.Order?> GetByIdAsync(Guid id) =>
await context.Orders.FindAsync(id);
public async Task<List<Models.Order>> GetAllAsync() =>
await context.Orders.ToListAsync();
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
public async Task AddAsync(Models.Order entity) =>
await context.Orders.AddAsync(entity);
public void Update(Models.Order entity) =>
context.Orders.Update(entity);
public void Remove(Models.Order entity)
{
entity.Status = Models.OrderStatus.Cancelled;
entity.IsActive = false;
context.Orders.Update(entity);
}
public async Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId) =>
await context.OrderItems.Where(oi => oi.FK_OrderId == orderId).ToListAsync();
public async Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId) =>
await context.Orders.Where(o => o.FK_StoreId == storeId && o.IsActive).ToListAsync();
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
await context.OrderItems.AddRangeAsync(items);
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Include(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
}
-158
View File
@@ -1,158 +0,0 @@
using Distribution.Features.Sale;
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.Store;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Order;
public class OrderService(
IOrderRepository repo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
public async Task<List<OrderResponse>> GetAllAsync()
{
// Deleted orders stay IsActive=false but remain visible with a Cancelled status —
// Delete never removes the row from the list, only marks it cancelled.
var orders = (await repo.GetAllAsync()).ToList();
if (!HasGlobalScope && currentUser.UserId is not null)
{
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
orders = orders.Where(o => accessibleStoreIds.Contains(o.FK_StoreId)).ToList();
}
var result = new List<OrderResponse>();
foreach (var order in orders)
{
var items = await repo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(order, items));
}
return result;
}
public async Task<OrderResponse?> GetByIdAsync(Guid id)
{
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
public async Task<List<OrderResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var orders = await repo.GetByStoreIdAsync(storeId);
var result = new List<OrderResponse>();
foreach (var order in orders)
{
var items = await repo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(order, items));
}
return result;
}
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
{
var sale = await saleRepo.GetByIdAsync(request.SaleId);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
?? throw new InvalidOperationException("Orders are created automatically from sales");
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
return ToResponse(existingOrder, items);
}
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
{
// TEMPORARY: see DeleteAsync/SaleService — store-scope enforcement depends on the broken
// auth middleware (testbug.md #28) and would reject every update. Skipped for now.
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order");
order.Status = request.Status;
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
sale.Status = request.Status switch
{
OrderStatus.Fulfilled => SaleStatus.Completed,
OrderStatus.Cancelled => SaleStatus.Cancelled,
_ => SaleStatus.Pending
};
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
await uow.BeginAsync(ct);
try
{
repo.Update(order);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
// TEMPORARY: see SaleService — store-scope enforcement depends on the broken auth
// middleware (testbug.md #28) and would reject every delete. Skipped for now.
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
await uow.BeginAsync(ct);
try
{
repo.Remove(order);
if (sale is not null)
{
sale.Status = SaleStatus.Cancelled;
sale.CompletedAt = null;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
}
@@ -1,38 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Route("api/payments")]
public class FlatPaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? storeId,
[FromQuery] Guid? saleId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(storeId, saleId, page, pageSize);
return Ok(result);
}
[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(CreateFlatPaymentRequest request, CancellationToken ct)
{
try
{
var inner = new CreatePaymentRequest(request.Amount, request.Method, request.CollectedByUserId);
var result = await service.CreateAsync(request.SaleId, inner, ct);
return result is null ? NotFound(new { error = "Sale not found or access denied" }) : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -1,12 +1,13 @@
using Distribution.Models; using Distribution.Models;
namespace Distribution.Features.Payment; namespace Distribution.Features.Payment;
public interface IPaymentRepository public interface IPaymentRepository
{ {
Task<Models.Payment?> GetByIdAsync(Guid id); Task<Models.Payment?> GetByIdAsync(Guid id);
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize); Task<List<Models.Payment>> GetAllAsync(Guid? invoiceId, int page, int pageSize);
Task<int> GetCountAsync(Guid? storeId, Guid? saleId); Task<int> GetCountAsync(Guid? invoiceId);
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId); Task<List<Models.Payment>> GetByInvoiceIdAsync(Guid invoiceId);
Task AddAsync(Models.Payment payment); Task AddAsync(Models.Payment payment);
void Update(Models.Payment payment);
} }
@@ -3,22 +3,22 @@
namespace Distribution.Features.Payment; namespace Distribution.Features.Payment;
[ApiController] [ApiController]
[Route("api/sales/{saleId:guid}/payments")] [Route("api/invoices/{invoiceId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase public class PaymentController(PaymentService service) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> GetBySale(Guid saleId) public async Task<IActionResult> GetByInvoice(Guid invoiceId)
{ {
var result = await service.GetBySaleIdAsync(saleId); var result = await service.GetByInvoiceIdAsync(invoiceId);
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct) public async Task<IActionResult> Create(Guid invoiceId, CreatePaymentRequest request, CancellationToken ct)
{ {
try try
{ {
var result = await service.CreateAsync(saleId, request, ct); var result = await service.CreateAsync(invoiceId, request, ct);
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
+9 -10
View File
@@ -1,25 +1,24 @@
using Distribution.Models; using Distribution.Models;
namespace Distribution.Features.Payment; namespace Distribution.Features.Payment;
public record CreatePaymentRequest( public record CreatePaymentRequest(
decimal Amount, decimal Amount,
PaymentMethod Method, PaymentMethod Method,
// TEMPORARY: auth middleware doesn't resolve a logged-in user yet — see testbug.md #28. EntryMode EntryMode = EntryMode.RealTime,
Guid? CollectedByUserId = null); string? ChequeNumber = null,
DateTime? ChequeDate = null,
public record CreateFlatPaymentRequest(
Guid SaleId,
decimal Amount,
PaymentMethod Method,
Guid? CollectedByUserId = null); Guid? CollectedByUserId = null);
public record PaymentResponse( public record PaymentResponse(
Guid Id, Guid Id,
Guid SaleId, Guid InvoiceId,
Guid StoreId,
decimal Amount, decimal Amount,
PaymentMethod Method, PaymentMethod Method,
EntryMode EntryMode,
string? ChequeNumber,
DateTime? ChequeDate,
DateTime CollectedAt, DateTime CollectedAt,
Guid CollectedBy, Guid CollectedBy,
Guid? RecordedBy,
DateTime CreatedAt); DateTime CreatedAt);
@@ -1,4 +1,4 @@
using Distribution.Data; using Distribution.Data;
using Distribution.Models; using Distribution.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -9,29 +9,27 @@ public class PaymentRepository(AppDBContext context) : IPaymentRepository
public async Task<Models.Payment?> GetByIdAsync(Guid id) => public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive); await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
public async Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) => public async Task<List<Models.Payment>> GetAllAsync(Guid? invoiceId, int page, int pageSize) =>
await context.Payments await context.Payments
.Where(p => p.IsActive .Where(p => p.IsActive && (invoiceId == null || p.FK_InvoiceId == invoiceId))
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.OrderByDescending(p => p.CollectedAt) .OrderByDescending(p => p.CollectedAt)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.ToListAsync(); .ToListAsync();
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) => public async Task<int> GetCountAsync(Guid? invoiceId) =>
await context.Payments await context.Payments
.Where(p => p.IsActive .CountAsync(p => p.IsActive && (invoiceId == null || p.FK_InvoiceId == invoiceId));
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.CountAsync();
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) => public async Task<List<Models.Payment>> GetByInvoiceIdAsync(Guid invoiceId) =>
await context.Payments await context.Payments
.Where(p => p.FK_SaleId == saleId && p.IsActive) .Where(p => p.FK_InvoiceId == invoiceId && p.IsActive)
.OrderBy(p => p.CollectedAt) .OrderBy(p => p.CollectedAt)
.ToListAsync(); .ToListAsync();
public async Task AddAsync(Models.Payment payment) => public async Task AddAsync(Models.Payment payment) =>
await context.Payments.AddAsync(payment); await context.Payments.AddAsync(payment);
public void Update(Models.Payment payment) =>
context.Payments.Update(payment);
} }
+32 -37
View File
@@ -1,5 +1,4 @@
using Distribution.Features.Sale; using Distribution.Features.Invoice;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -8,55 +7,52 @@ namespace Distribution.Features.Payment;
public class PaymentService( public class PaymentService(
IPaymentRepository repo, IPaymentRepository repo,
ISaleRepository saleRepo, IInvoiceRepository invoiceRepo,
IUnitOfWork uow, IUnitOfWork uow,
ICurrentUserService currentUser) ICurrentUserService currentUser)
{ {
private static PaymentResponse ToResponse(Models.Payment p) => private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt); new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode, p.ChequeNumber, p.ChequeDate,
p.CollectedAt, p.CollectedBy, p.RecordedBy, p.CreatedAt);
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId)
{ {
var total = await repo.GetCountAsync(storeId, saleId); var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize); if (invoice is null || !invoice.IsActive) return null;
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<PaymentResponse?> GetByIdAsync(Guid id) var payments = await repo.GetByInvoiceIdAsync(invoiceId);
{
var payment = await repo.GetByIdAsync(id);
return payment is null ? null : ToResponse(payment);
}
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
var payments = await repo.GetBySaleIdAsync(saleId);
return payments.Select(ToResponse).ToList(); return payments.Select(ToResponse).ToList();
} }
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default) public async Task<PaymentResponse?> CreateAsync(Guid invoiceId, CreatePaymentRequest request, CancellationToken ct = default)
{ {
var sale = await saleRepo.GetByIdAsync(saleId); var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
if (sale is null || !sale.IsActive) return null; if (invoice is null || !invoice.IsActive) return null;
if (sale.Status == SaleStatus.Cancelled) if (invoice.Status == InvoiceStatus.Finalized)
throw new InvalidOperationException("Cannot record a payment against a cancelled sale"); throw new InvalidOperationException("Cannot record a payment against a finalized invoice");
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware if (request.Method == PaymentMethod.Cheque &&
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed. (string.IsNullOrWhiteSpace(request.ChequeNumber) || request.ChequeDate is null))
var userId = currentUser.UserId ?? request.CollectedByUserId throw new InvalidOperationException("ChequeNumber and ChequeDate are required when Method is Cheque");
var recorderId = currentUser.UserId ?? request.CollectedByUserId
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable"); ?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
var collectedBy = request.CollectedByUserId ?? recorderId;
var recordedBy = request.EntryMode == EntryMode.DayEndManual && collectedBy != recorderId
? recorderId
: (Guid?)null;
var payment = new Models.Payment var payment = new Models.Payment
{ {
FK_SaleId = saleId, FK_InvoiceId = invoiceId,
FK_StoreId = sale.FK_StoreId,
Amount = request.Amount, Amount = request.Amount,
Method = request.Method, Method = request.Method,
EntryMode = request.EntryMode,
ChequeNumber = request.Method == PaymentMethod.Cheque ? request.ChequeNumber : null,
ChequeDate = request.Method == PaymentMethod.Cheque ? request.ChequeDate : null,
CollectedAt = DateTime.Now, CollectedAt = DateTime.Now,
CollectedBy = userId CollectedBy = collectedBy,
RecordedBy = recordedBy
}; };
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
@@ -64,17 +60,16 @@ public class PaymentService(
{ {
await repo.AddAsync(payment); await repo.AddAsync(payment);
var allPayments = await repo.GetBySaleIdAsync(saleId); var allPayments = await repo.GetByInvoiceIdAsync(invoiceId);
var total = allPayments.Sum(p => p.Amount) + request.Amount; var total = allPayments.Sum(p => p.Amount) + request.Amount;
sale.PaymentStatus = total == 0 invoice.PaymentStatus = total == 0
? PaymentStatus.Pending ? PaymentStatus.Pending
: total < sale.TotalAmount : total < invoice.TotalAmount
? PaymentStatus.PartiallyPaid ? PaymentStatus.PartiallyPaid
: PaymentStatus.Paid; : PaymentStatus.Paid;
saleRepo.Update(sale); invoiceRepo.Update(invoice);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
@@ -42,5 +42,5 @@ public interface IRegionRepository
Task<List<Models.Route>> GetRoutesAsync(Guid regionId); Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds); Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive); Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
} }
@@ -129,11 +129,11 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) => public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) =>
await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync(); await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync();
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) => public async Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
await (from o in context.Orders await (from o in context.ShopPOs
join s in context.Stores on o.FK_StoreId equals s.Id join s in context.Stores on o.FK_StoreId equals s.Id
join r in context.Routes on s.FK_RouteId equals r.Id join r in context.Routes on s.FK_RouteId equals r.Id
where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive
&& o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive
select o).ToListAsync(); select o).ToListAsync();
} }
@@ -219,7 +219,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
budget?.BudgetAmount, budget?.BudgetAmount,
orders.Count, orders.Count,
orders.Sum(o => o.TotalAmount), orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate), orders.Count == 0 ? null : orders.Max(o => o.CreatedAt),
taxRule?.RuleConfig, taxRule?.RuleConfig,
agencyMaps.Select(m => m.FK_AgencyUserId).ToList()); agencyMaps.Select(m => m.FK_AgencyUserId).ToList());
} }
@@ -4,11 +4,11 @@ public interface IReturnRepository
{ {
Task<Models.SaleReturn?> GetByIdAsync(Guid id); Task<Models.SaleReturn?> GetByIdAsync(Guid id);
Task<List<Models.SaleReturn>> GetAllAsync(); Task<List<Models.SaleReturn>> GetAllAsync();
Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId); Task<List<Models.SaleReturn>> GetByShopPOIdAsync(Guid shopPOId);
Task AddAsync(Models.SaleReturn entity); Task AddAsync(Models.SaleReturn entity);
void Update(Models.SaleReturn entity); void Update(Models.SaleReturn entity);
Task AddCreditNoteAsync(Models.CreditNote entity); Task AddCreditNoteAsync(Models.CreditNote entity);
/// <summary>Whether this store has ever had this item on any of its (active) orders.</summary> /// <summary>Whether this store has ever had this item on any of its (active) ShopPOs.</summary>
Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId); Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId);
} }
@@ -1,4 +1,4 @@
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Returns; namespace Distribution.Features.Returns;
@@ -28,15 +28,13 @@ public class ReturnController(ReturnService service, ICurrentUserService current
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
} }
[HttpGet("api/orders/{id:guid}/returns")] [HttpGet("api/shop-pos/{id:guid}/returns")]
public async Task<IActionResult> GetByOrder(Guid id) => public async Task<IActionResult> GetByShopPO(Guid id) =>
Ok(await service.GetByOrderIdAsync(id)); Ok(await service.GetByShopPOIdAsync(id));
[HttpPost("api/orders/{id:guid}/returns")] [HttpPost("api/shop-pos/{id:guid}/returns")]
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct) public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
{ {
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.CreatedByUserId; var userId = currentUser.UserId ?? request.CreatedByUserId;
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" }); if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
try try
@@ -44,6 +42,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
var result = await service.CreateAsync(id, request, userId.Value, ct); var result = await service.CreateAsync(id, request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
} }
catch (NotImplementedException ex) { return StatusCode(501, new { error = ex.Message }); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
} }
} }
+1 -1
View File
@@ -27,7 +27,7 @@ public record SaleReturnItemResponse(
public record SaleReturnResponse( public record SaleReturnResponse(
Guid Id, Guid Id,
Guid OrderId, Guid ShopPOId,
ReturnMode Mode, ReturnMode Mode,
decimal TotalCreditAmount, decimal TotalCreditAmount,
decimal OrderTotalAmountAfter, decimal OrderTotalAmountAfter,
@@ -7,23 +7,23 @@ public class ReturnRepository(AppDBContext context) : IReturnRepository
{ {
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) => public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
await context.SaleReturns await context.SaleReturns
.Include(r => r.Order) .Include(r => r.ShopPO)
.Include(r => r.Items).ThenInclude(i => i.Item) .Include(r => r.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(r => r.Id == id); .FirstOrDefaultAsync(r => r.Id == id);
public async Task<List<Models.SaleReturn>> GetAllAsync() => public async Task<List<Models.SaleReturn>> GetAllAsync() =>
await context.SaleReturns await context.SaleReturns
.Include(r => r.Order) .Include(r => r.ShopPO)
.Include(r => r.Items).ThenInclude(i => i.Item) .Include(r => r.Items).ThenInclude(i => i.Item)
.Where(r => r.IsActive) .Where(r => r.IsActive)
.OrderByDescending(r => r.CreatedAt) .OrderByDescending(r => r.CreatedAt)
.ToListAsync(); .ToListAsync();
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) => public async Task<List<Models.SaleReturn>> GetByShopPOIdAsync(Guid shopPOId) =>
await context.SaleReturns await context.SaleReturns
.Include(r => r.Order) .Include(r => r.ShopPO)
.Include(r => r.Items).ThenInclude(i => i.Item) .Include(r => r.Items).ThenInclude(i => i.Item)
.Where(r => r.FK_OrderId == orderId && r.IsActive) .Where(r => r.FK_ShopPOId == shopPOId && r.IsActive)
.OrderByDescending(r => r.CreatedAt) .OrderByDescending(r => r.CreatedAt)
.ToListAsync(); .ToListAsync();
@@ -37,7 +37,7 @@ public class ReturnRepository(AppDBContext context) : IReturnRepository
await context.CreditNotes.AddAsync(entity); await context.CreditNotes.AddAsync(entity);
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) => public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
await context.OrderItems await context.ShopPOItems
.Include(oi => oi.Order) .Include(oi => oi.ShopPO)
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId); .AnyAsync(oi => oi.ShopPO.FK_StoreId == storeId && oi.ShopPO.IsActive && oi.FK_ItemId == itemId);
} }
+7 -143
View File
@@ -1,7 +1,5 @@
using Distribution.Features.Item; using Distribution.Features.Item;
using Distribution.Features.Order; using Distribution.Features.ShopPO;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -10,9 +8,7 @@ namespace Distribution.Features.Returns;
public class ReturnService( public class ReturnService(
IReturnRepository repo, IReturnRepository repo,
IOrderRepository orderRepo, IShopPORepository shopPORepo,
ISaleRepository saleRepo,
IRideRepository rideRepo,
IItemRepository itemRepo, IItemRepository itemRepo,
IStockRepository stockRepo, IStockRepository stockRepo,
IUnitOfWork uow) IUnitOfWork uow)
@@ -21,7 +17,7 @@ public class ReturnService(
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice); new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice);
private static SaleReturnResponse Map(SaleReturn r) => private static SaleReturnResponse Map(SaleReturn r) =>
new(r.Id, r.FK_OrderId, r.Mode, r.TotalCreditAmount, r.Order.TotalAmount, r.IsActive, r.CreatedAt, new(r.Id, r.FK_ShopPOId, r.Mode, r.TotalCreditAmount, r.ShopPO.TotalAmount, r.IsActive, r.CreatedAt,
r.Items.Where(i => i.IsActive).Select(MapItem).ToList()); r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id) public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
@@ -58,141 +54,9 @@ public class ReturnService(
return Map(r); return Map(r);
} }
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) => public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) =>
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList(); (await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList();
/// <summary> public Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) =>
/// Records a return recognized during the same store visit as an open order and settles it throw new NotImplementedException("Return flow is being redesigned — not yet implemented.");
/// immediately: stock is credited back to the rep's vehicle, and either the order total is
/// reduced (CreditDeduction) or the matching order line quantities are reduced (ItemExchange).
/// A return is only allowed for an item the store has previously ordered.
/// </summary>
public async Task<SaleReturnResponse> CreateAsync(Guid orderId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
{
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive)
throw new InvalidOperationException("Order not found.");
if (order.Status is OrderStatus.Cancelled or OrderStatus.Fulfilled)
throw new InvalidOperationException("Cannot record a return against a closed order.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A return must have at least one line item.");
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order.");
if (sale.FK_RideId is null)
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
var ride = await rideRepo.GetByIdAsync(sale.FK_RideId.Value)
?? throw new InvalidOperationException("Ride not found.");
var vehicleId = ride.FK_VehicleId;
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var orderItemsByItemId = orderItems.ToDictionary(oi => oi.FK_ItemId);
var saleReturn = new SaleReturn
{
FK_OrderId = orderId,
Mode = request.Mode,
CreatedByUserId = createdByUserId
};
foreach (var line in request.Items)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
var item = await itemRepo.GetByIdAsync(line.ItemId)
?? throw new InvalidOperationException($"Item {line.ItemId} not found.");
if (!await repo.StoreHasPriorSaleAsync(order.FK_StoreId, line.ItemId))
throw new InvalidOperationException($"No prior sale of item '{item.Name}' found for this store.");
var unitPrice = orderItemsByItemId.TryGetValue(line.ItemId, out var matchingOrderItem)
? matchingOrderItem.UnitPrice
: item.SalePrice ?? 0;
saleReturn.Items.Add(new SaleReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
ReasonCode = line.ReasonCode,
UnitPrice = unitPrice
});
}
await uow.BeginAsync(ct);
try
{
foreach (var line in saleReturn.Items)
{
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, vehicleId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = line.FK_ItemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = line.Quantity
});
}
else
{
balance.Quantity += line.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = line.FK_ItemId,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = line.Quantity,
Type = MovementType.CustomerReturn,
ReasonCode = line.ReasonCode.ToString(),
ReferenceId = saleReturn.Id,
CreatedBy = createdByUserId
});
}
await repo.AddAsync(saleReturn);
if (request.Mode == ReturnMode.CreditDeduction)
{
var total = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
saleReturn.TotalCreditAmount = total;
order.TotalAmount = Math.Max(0, order.TotalAmount - total);
orderRepo.Update(order);
await repo.AddCreditNoteAsync(new CreditNote
{
FK_SaleReturnId = saleReturn.Id,
Amount = total
});
}
else // ItemExchange
{
foreach (var line in saleReturn.Items)
{
if (!orderItemsByItemId.TryGetValue(line.FK_ItemId, out var orderItem))
continue; // no matching line on this order — nothing to offset
var offsetQty = Math.Min(line.Quantity, orderItem.Quantity);
orderItem.Quantity -= (int)offsetQty;
order.TotalAmount = Math.Max(0, order.TotalAmount - offsetQty * orderItem.UnitPrice);
}
orderRepo.Update(order);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(saleReturn.Id);
return Map(saved!);
}
} }
@@ -1,14 +0,0 @@
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);
}
+4 -13
View File
@@ -1,22 +1,13 @@
using Distribution.Models; using Distribution.Models;
namespace Distribution.Features.Ride; namespace Distribution.Features.Ride;
public interface IRideRepository public interface IRideRepository
{ {
Task<DeliveryRun?> GetByIdAsync(Guid id); Task<DeliveryRun?> GetByIdAsync(Guid id);
Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize); Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize);
Task<int> GetCountAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId); Task<int> GetCountAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId);
Task<bool> HasOpenOrActiveRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null); Task<bool> HasOpenRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null);
Task<List<DeliveryRun>> GetActiveRidesAsync(Guid? excludeRideId = null);
Task<List<OrderSummaryModel>> GetActiveOrdersByRouteIdAsync(Guid routeId);
Task AddAsync(DeliveryRun ride); Task AddAsync(DeliveryRun ride);
void Update(DeliveryRun ride); void Update(DeliveryRun ride);
} }
public record OrderSummaryModel(
Guid Id,
Guid StoreId,
string StoreName,
DateTime OrderDate,
decimal TotalAmount);
@@ -1,39 +0,0 @@
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);
}
+10 -89
View File
@@ -1,23 +1,21 @@
using Distribution.Infra.Authorization; using Distribution.Models;
using Distribution.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Ride; namespace Distribution.Features.Ride;
[ApiController] [ApiController]
[Route("api/rides")] [Route("api/rides")]
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase public class RideController(RideService service) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> GetAll( public async Task<IActionResult> GetAll(
[FromQuery] RideStatus? status, [FromQuery] RideStatus? status,
[FromQuery] RideType? type,
[FromQuery] Guid? driverProfileId, [FromQuery] Guid? driverProfileId,
[FromQuery] Guid? vehicleId, [FromQuery] Guid? vehicleId,
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20) [FromQuery] int pageSize = 20)
{ {
var result = await service.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize); var result = await service.GetAllAsync(status, driverProfileId, vehicleId, page, pageSize);
return Ok(result); return Ok(result);
} }
@@ -30,10 +28,14 @@ public class RideController(RideService service, ICurrentUserService currentUser
[HttpPost] [HttpPost]
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct) public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
{
try
{ {
var result = await service.CreateAsync(request, ct); var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
} }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/driver")] [HttpPut("{id:guid}/driver")]
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct) public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
@@ -47,32 +49,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
} }
[HttpPost("{id:guid}/open")]
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 }); }
}
[HttpPatch("{id:guid}/activate")]
[HttpPost("{id:guid}/activate")]
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 }); }
}
[HttpPatch("{id:guid}/close")]
[HttpPost("{id:guid}/close")] [HttpPost("{id:guid}/close")]
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct) public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
{ {
@@ -85,70 +61,15 @@ public class RideController(RideService service, ICurrentUserService currentUser
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
} }
[HttpPost("{id:guid}/cancel-request")] [HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct) public async Task<IActionResult> Cancel(Guid id, CancelRideRequest request, CancellationToken ct)
{ {
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try try
{ {
var result = await service.RaiseCancelRequestAsync(id, request, userId, ct); var result = await service.CancelAsync(id, request, ct);
return Ok(result); return Ok(result);
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
} }
[HttpGet("cancel-requests")]
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")]
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")]
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")]
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}")]
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
{
try
{
var result = await service.GetBreakdownAssistAsync(rideId);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
}
} }
+3 -49
View File
@@ -1,73 +1,27 @@
using Distribution.Models; using Distribution.Models;
using Distribution.Features.Stock;
namespace Distribution.Features.Ride; namespace Distribution.Features.Ride;
// Ride (DeliveryRun)
public record RideResponse( public record RideResponse(
Guid Id, Guid Id,
RideType RideType,
RideStatus Status, RideStatus Status,
Guid VehicleId, Guid VehicleId,
string VehicleNumberPlate, string VehicleNumberPlate,
Guid DriverProfileId, Guid DriverProfileId,
string DriverFullName, string DriverFullName,
Guid RouteId,
string RouteName,
Guid SourceWarehouseId,
string SourceWarehouseName,
int Meterstart, int Meterstart,
int Meterend, int Meterend,
DateTime? StartedAt, DateTime? StartedAt,
DateTime? ActivatedAt,
DateTime? ClosedAt, DateTime? ClosedAt,
DateTime CreatedAt); DateTime CreatedAt);
public record CreateRideRequest( public record CreateRideRequest(
RideType RideType,
Guid VehicleId, Guid VehicleId,
Guid DriverProfileId, Guid DriverProfileId,
Guid RouteId, int Meterstart);
Guid? SourceWarehouseId);
public record AssignRideDriverRequest(Guid DriverProfileId); public record AssignRideDriverRequest(Guid DriverProfileId);
public record ActivateRideRequest(int Meterstart);
public record CloseRideRequest(int Meterend); public record CloseRideRequest(int Meterend);
public record RaiseCancelRequest(CancelRequestType TypeCode, string Reason); public record CancelRideRequest(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);
+5 -32
View File
@@ -1,4 +1,4 @@
using Distribution.Data; using Distribution.Data;
using Distribution.Models; using Distribution.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -10,59 +10,32 @@ public class RideRepository(AppDBContext context) : IRideRepository
await context.DeliveryRuns await context.DeliveryRuns
.Include(r => r.Vehicle) .Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User) .Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Include(r => r.SourceWarehouse)
.FirstOrDefaultAsync(r => r.Id == id); .FirstOrDefaultAsync(r => r.Id == id);
public async Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) => public async Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) =>
await context.DeliveryRuns await context.DeliveryRuns
.Include(r => r.Vehicle) .Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User) .Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Include(r => r.SourceWarehouse)
.Where(r => .Where(r =>
(status == null || r.Status == status) && (status == null || r.Status == status) &&
(type == null || r.RideType == type) &&
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) && (driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
(vehicleId == null || r.FK_VehicleId == vehicleId)) (vehicleId == null || r.FK_VehicleId == vehicleId))
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.ToListAsync(); .ToListAsync();
public async Task<int> GetCountAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId) => public async Task<int> GetCountAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId) =>
await context.DeliveryRuns await context.DeliveryRuns
.CountAsync(r => .CountAsync(r =>
(status == null || r.Status == status) && (status == null || r.Status == status) &&
(type == null || r.RideType == type) &&
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) && (driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
(vehicleId == null || r.FK_VehicleId == vehicleId)); (vehicleId == null || r.FK_VehicleId == vehicleId));
public async Task<bool> HasOpenOrActiveRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null) => public async Task<bool> HasOpenRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null) =>
await context.DeliveryRuns.AnyAsync(r => await context.DeliveryRuns.AnyAsync(r =>
r.FK_VehicleId == vehicleId && r.FK_VehicleId == vehicleId &&
r.Id != excludeRideId && r.Id != excludeRideId &&
(r.Status == RideStatus.Open || r.Status == RideStatus.Active)); r.Status == RideStatus.Open);
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) => public async Task AddAsync(DeliveryRun ride) =>
await context.DeliveryRuns.AddAsync(ride); await context.DeliveryRuns.AddAsync(ride);
+21 -225
View File
@@ -1,4 +1,4 @@
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Features.DriverComplianceConfig; using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile; using Distribution.Features.DriverProfile;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
@@ -10,21 +10,13 @@ public class RideService(
IRideRepository rideRepo, IRideRepository rideRepo,
IDriverProfileRepository driverRepo, IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo, IDriverComplianceConfigRepository complianceRepo,
IRideCancelRequestRepository cancelRepo,
Features.Vehicle.IVehicleRepository vehicleRepo,
IStockRepository stockRepo, IStockRepository stockRepo,
IUnitOfWork uow) IUnitOfWork uow)
{ {
public async Task<PagedResponse<RideResponse>> GetAllAsync( public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
RideStatus? status,
RideType? type,
Guid? driverProfileId,
Guid? vehicleId,
int page,
int pageSize)
{ {
var total = await rideRepo.GetCountAsync(status, type, driverProfileId, vehicleId); var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
var items = await rideRepo.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize); var items = await rideRepo.GetAllAsync(status, driverProfileId, vehicleId, page, pageSize);
return new PagedResponse<RideResponse>(page, pageSize, total, items.Select(ToResponse).ToList()); return new PagedResponse<RideResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
} }
@@ -36,21 +28,18 @@ public class RideService(
public async Task<RideResponse> CreateAsync(CreateRideRequest request, CancellationToken ct = default) public async Task<RideResponse> CreateAsync(CreateRideRequest request, CancellationToken ct = default)
{ {
var vehicle = await vehicleRepo.GetByIdAsync(request.VehicleId) if (await rideRepo.HasOpenRideForVehicleAsync(request.VehicleId))
?? throw new InvalidOperationException("Vehicle not found"); throw new InvalidOperationException("Vehicle already has an open ride");
var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.SourceWarehouseId; await CheckDriverComplianceAsync(request.DriverProfileId);
if (!sourceWarehouseId.HasValue)
throw new InvalidOperationException("Source warehouse is required for this vehicle");
var ride = new DeliveryRun var ride = new DeliveryRun
{ {
RideType = request.RideType,
FK_VehicleId = request.VehicleId, FK_VehicleId = request.VehicleId,
FK_DriverProfileId = request.DriverProfileId, FK_DriverProfileId = request.DriverProfileId,
FK_RouteId = request.RouteId, Status = RideStatus.Open,
FK_SourceWarehouseId = sourceWarehouseId.Value, Meterstart = request.Meterstart,
Status = RideStatus.Draft StartedAt = DateTime.Now
}; };
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
@@ -59,46 +48,19 @@ public class RideService(
await rideRepo.AddAsync(ride); await rideRepo.AddAsync(ride);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch catch { await uow.RollbackAsync(ct); throw; }
{
await uow.RollbackAsync(ct);
throw;
}
var saved = await rideRepo.GetByIdAsync(ride.Id); var saved = await rideRepo.GetByIdAsync(ride.Id);
return ToResponse(saved!); 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) public async Task<RideResponse> AssignDriverAsync(Guid rideId, Guid driverProfileId, CancellationToken ct = default)
{ {
var ride = await rideRepo.GetByIdAsync(rideId) var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found"); ?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Draft) if (ride.Status != RideStatus.Open)
throw new InvalidOperationException("Driver can only be changed while the ride is in Draft status"); throw new InvalidOperationException("Driver can only be changed while the ride is Open");
var driver = await driverRepo.GetByIdAsync(driverProfileId); var driver = await driverRepo.GetByIdAsync(driverProfileId);
if (driver is null || !driver.IsActive) if (driver is null || !driver.IsActive)
@@ -114,31 +76,12 @@ public class RideService(
return ToResponse(saved!); 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) public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default)
{ {
var ride = await rideRepo.GetByIdAsync(rideId) var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found"); ?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active) if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status"); throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
if (request.Meterend < ride.Meterstart) if (request.Meterend < ride.Meterstart)
@@ -157,162 +100,21 @@ public class RideService(
return ToResponse(ride); return ToResponse(ride);
} }
public async Task<RideCancelRequestResponse> RaiseCancelRequestAsync(Guid rideId, RaiseCancelRequest request, Guid requestedBy, CancellationToken ct = default) public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest request, CancellationToken ct = default)
{ {
var ride = await rideRepo.GetByIdAsync(rideId) var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found"); ?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active) if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot raise a cancel request for a ride in {ride.Status} status"); throw new InvalidOperationException($"Cannot cancel 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; ride.Status = RideStatus.Cancelled;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
{
cancelRepo.Update(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
return ToCancelResponse(cancelRequest); return ToResponse(ride);
}
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,
ride.Vehicle?.NumberPlate ?? string.Empty)).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) private async Task CheckDriverComplianceAsync(Guid driverProfileId)
@@ -343,15 +145,9 @@ public class RideService(
} }
private static RideResponse ToResponse(DeliveryRun r) => private static RideResponse ToResponse(DeliveryRun r) =>
new(r.Id, r.RideType, r.Status, new(r.Id, r.Status,
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty, r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
r.FK_DriverProfileId, r.DriverProfile?.User?.FullName ?? 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.Meterstart, r.Meterend,
r.StartedAt, r.ActivatedAt, r.ClosedAt, r.CreatedAt); r.StartedAt, 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);
} }
@@ -1,15 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public interface ISaleRepository
{
Task<Models.Sale?> GetByIdAsync(Guid id);
Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds);
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.Sale sale);
void Update(Models.Sale sale);
void Remove(Models.Sale sale);
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
}
@@ -1,51 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.GetAllAsync(rideId, page, pageSize));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
try
{
var result = await service.CancelAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
-36
View File
@@ -1,36 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public record SaleItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateSaleRequest(
Guid StoreId,
DateTime SaleDate,
decimal TotalAmount,
bool FulfillImmediately,
Guid? RideId,
List<SaleItemRequest> Items,
// TEMPORARY: auth middleware doesn't attach a resolvable user yet (see testbug.md #28), so the
// caller supplies the acting user explicitly. Remove once currentUser.UserId is reliable again.
Guid? CreatedByUserId = null);
public record SaleItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record SaleResponse(
Guid Id,
DateTime SaleDate,
Guid StoreId,
Guid CreatedByUserId,
Guid? RideId,
decimal TotalAmount,
SaleStatus Status,
PaymentStatus PaymentStatus,
bool FulfillImmediately,
DateTime? CompletedAt,
Guid OrderId,
OrderStatus OrderStatus,
DateTime? OrderFulfilledAt,
List<SaleItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -1,45 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Sale;
public class SaleRepository(AppDBContext context) : ISaleRepository
{
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
await context.Sales.FindAsync(id);
public async Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize) =>
await context.Sales
.Where(s => s.IsActive &&
(rideId == null || s.FK_RideId == rideId) &&
(storeIds == null || storeIds.Contains(s.FK_StoreId)))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds) =>
await context.Sales
.CountAsync(s => s.IsActive &&
(rideId == null || s.FK_RideId == rideId) &&
(storeIds == null || storeIds.Contains(s.FK_StoreId)));
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
public async Task AddAsync(Models.Sale sale) =>
await context.Sales.AddAsync(sale);
public void Update(Models.Sale sale) =>
context.Sales.Update(sale);
public void Remove(Models.Sale sale)
{
sale.IsActive = false;
context.Sales.Update(sale);
}
public async Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
}
-273
View File
@@ -1,273 +0,0 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Sale;
public class SaleService(
ISaleRepository repo,
IOrderRepository orderRepo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
PriceListService priceListService,
IStockRepository stockRepo,
IRideRepository rideRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime saleDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, saleDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
private static SaleResponse ToResponse(Models.Sale sale, Models.Order order, List<Models.OrderItem> items) =>
new(
sale.Id,
sale.SaleDate,
sale.FK_StoreId,
sale.CreatedByUserId,
sale.FK_RideId,
sale.TotalAmount,
sale.Status,
sale.PaymentStatus,
sale.FulfillImmediately,
sale.CompletedAt,
order.Id,
order.Status,
order.FulfilledAt,
items.Select(i => new SaleItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
sale.IsActive,
sale.CreatedAt);
public async Task<PagedResponse<SaleResponse>> GetAllAsync(Guid? rideId, int page, int pageSize)
{
IEnumerable<Guid>? storeIds = null;
if (!HasGlobalScope && currentUser.UserId is not null)
storeIds = await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value);
var total = await repo.GetCountAsync(rideId, storeIds);
var sales = await repo.GetAllAsync(rideId, storeIds, page, pageSize);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return new PagedResponse<SaleResponse>(page, pageSize, total, result);
}
public async Task<SaleResponse?> GetByIdAsync(Guid id)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<List<SaleResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var sales = await repo.GetByStoreIdAsync(storeId);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> CancelAsync(Guid id, CancellationToken ct = default)
{
// TEMPORARY: see CreateAsync — store-scope enforcement depends on the broken auth
// middleware (testbug.md #28) and would reject every cancel. Skipped for now.
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Sale is already cancelled");
sale.Status = SaleStatus.Cancelled;
await uow.BeginAsync(ct);
try { repo.Update(sale); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
{
// TEMPORARY: store-scope enforcement and currentUser resolution both depend on the broken
// auth middleware (see testbug.md #28) — currentUser.UserId/RoleLevel are always unset, so
// both checks would reject every sale. Skipped for now; restore once auth is fixed.
var userId = currentUser.UserId ?? request.CreatedByUserId
?? throw new InvalidOperationException("CreatedByUserId is required while auth is unavailable");
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId)
?? throw new InvalidOperationException("Store not found");
if (!store.IsActive)
throw new InvalidOperationException("Store is inactive");
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold)
throw new InvalidOperationException(
$"Store KYC is not verified — sale amount ({request.TotalAmount}) exceeds the unverified-store threshold ({threshold})");
}
var sale = new Models.Sale
{
FK_StoreId = request.StoreId,
SaleDate = request.SaleDate,
CreatedByUserId = userId,
FK_RideId = request.RideId,
FulfillImmediately = request.FulfillImmediately
};
var orderItems = new List<Models.OrderItem>();
foreach (var item in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate)
?? throw new InvalidOperationException(
$"Could not resolve a price for item {item.ItemId} on {request.SaleDate:yyyy-MM-dd} — check the item exists, an active price list covers this date/region, and any supplied unit price is within its min/max range");
orderItems.Add(new Models.OrderItem
{
FK_ItemId = item.ItemId,
Quantity = item.Quantity,
UnitPrice = unitPrice
});
}
var order = new Models.Order
{
FK_SaleId = sale.Id,
FK_StoreId = request.StoreId,
OrderDate = request.SaleDate,
};
foreach (var orderItem in orderItems)
orderItem.FK_OrderId = order.Id;
// For a van sale against a specific ride, sell whatever quantity is actually on that
// vehicle instead of rejecting the whole sale over one short item — the sale is always a
// clean, completed sale of what's handed over, never a "partially fulfilled" order.
Guid? vehicleId = null;
if (request.FulfillImmediately && request.RideId.HasValue)
{
var ride = await rideRepo.GetByIdAsync(request.RideId.Value)
?? throw new InvalidOperationException("Ride not found");
vehicleId = ride.FK_VehicleId;
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
item.Quantity = Math.Min(item.Quantity, (int)(balance?.Quantity ?? 0));
}
}
foreach (var item in orderItems)
item.QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0;
if (request.FulfillImmediately)
{
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
order.Status = OrderStatus.Fulfilled;
order.FulfilledAt = DateTime.Now;
}
else
{
sale.Status = SaleStatus.Pending;
order.Status = OrderStatus.Pending;
}
sale.TotalAmount = orderItems.Sum(i => i.Quantity * i.UnitPrice);
order.TotalAmount = sale.TotalAmount;
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sale);
await orderRepo.AddAsync(order);
await orderRepo.AddItemsAsync(orderItems);
if (vehicleId.HasValue)
{
foreach (var item in orderItems.Where(i => i.QuantityFulfilled > 0))
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
balance!.Quantity -= item.QuantityFulfilled;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId.Value,
FromLocationType = LocationType.Vehicle,
ToLocationId = StockService.ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = order.Id
});
}
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(sale, order, orderItems);
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ public record SchemeEligibilityResponse(
public record SchemeRedemptionResponse( public record SchemeRedemptionResponse(
Guid Id, Guid Id,
Guid SchemeId, Guid SchemeId,
Guid? OrderId, Guid? ShopPOId,
decimal? DiscountAmount, decimal? DiscountAmount,
Guid? FreeItemId, Guid? FreeItemId,
string? FreeItemName, string? FreeItemName,
@@ -42,15 +42,15 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
public async Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId) => public async Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId) =>
await context.SchemeRedemptions await context.SchemeRedemptions
.Include(r => r.FreeItem) .Include(r => r.FreeItem)
.Include(r => r.Order) .Include(r => r.ShopPO)
.Where(r => r.FK_SchemeId == schemeId) .Where(r => r.FK_SchemeId == schemeId)
.ToListAsync(); .ToListAsync();
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) => public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
await context.SchemeRedemptions.AddAsync(entity); await context.SchemeRedemptions.AddAsync(entity);
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId) => public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId); await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) => public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
await context.SchemeBundleComponents await context.SchemeBundleComponents
+36 -36
View File
@@ -1,6 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Distribution.Features.Item; using Distribution.Features.Item;
using Distribution.Features.Order; using Distribution.Features.ShopPO;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Features.Store; using Distribution.Features.Store;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
@@ -10,7 +10,7 @@ namespace Distribution.Features.Scheme;
public class SchemeService( public class SchemeService(
ISchemeRepository repo, ISchemeRepository repo,
IOrderRepository orderRepo, IShopPORepository shopPORepo,
IItemRepository itemRepo, IItemRepository itemRepo,
IStoreRepository storeRepo, IStoreRepository storeRepo,
IStockRepository stockRepo, IStockRepository stockRepo,
@@ -27,7 +27,7 @@ public class SchemeService(
s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt); s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt);
private static SchemeRedemptionResponse ToRedemptionResponse(SchemeRedemption r) => private static SchemeRedemptionResponse ToRedemptionResponse(SchemeRedemption r) =>
new(r.Id, r.FK_SchemeId, r.FK_OrderId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name, new(r.Id, r.FK_SchemeId, r.FK_ShopPOId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
r.FreeQty, r.FreeItemCostValue, r.RedeemedAt); r.FreeQty, r.FreeItemCostValue, r.RedeemedAt);
// ── CRUD ─────────────────────────────────────────────────────────────── // ── CRUD ───────────────────────────────────────────────────────────────
@@ -243,17 +243,17 @@ public class SchemeService(
return (await GetByIdAsync(schemeId))!; return (await GetByIdAsync(schemeId))!;
} }
// ── evaluateSchemeEligibility(storeId, orderId) ────────────────────────── // ── evaluateSchemeEligibility(storeId, shopPOId) ──────────────────────────
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid orderId) public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid shopPOId)
{ {
var store = await storeRepo.GetByIdWithRegionAsync(storeId); var store = await storeRepo.GetByIdWithRegionAsync(storeId);
if (store is null || !store.IsActive) return null; if (store is null || !store.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId); var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (order is null || !order.IsActive) return null; if (shopPO is null || !shopPO.IsActive) return null;
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId); var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
var regionId = store.Route?.FK_RegionId; var regionId = store.Route?.FK_RegionId;
var candidates = (await repo.GetAllAsync()).Where(s => s.IsActive); var candidates = (await repo.GetAllAsync()).Where(s => s.IsActive);
@@ -266,21 +266,21 @@ public class SchemeService(
if (!string.IsNullOrEmpty(scheme.EligibleSegment) && if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase)) !string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
continue; continue;
if (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo) if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
continue; continue;
result.Add(EvaluateRule(scheme, orderItems)); result.Add(EvaluateRule(scheme, items));
} }
return result; return result;
} }
/// <summary> /// <summary>
/// Scope-only check (region, segment, date range as of today) with no order to evaluate line /// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)` /// 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 /// (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", /// 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). /// not "this ShopPO's items satisfy the rule" (use EvaluateEligibilityAsync for that).
/// </summary> /// </summary>
public async Task<(bool Eligible, string? Reason)?> CheckStoreScopeEligibilityAsync(Guid storeId, Guid schemeId) public async Task<(bool Eligible, string? Reason)?> CheckStoreScopeEligibilityAsync(Guid storeId, Guid schemeId)
{ {
@@ -305,7 +305,7 @@ public class SchemeService(
return (true, null); return (true, null);
} }
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<OrderItem> orderItems) private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
{ {
switch (scheme.RuleType) switch (scheme.RuleType)
{ {
@@ -316,10 +316,10 @@ public class SchemeService(
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Scheme has no free-goods rule configured.", null, null, null); "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); var boughtQty = items.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity);
if (boughtQty < scheme.TriggerQty) if (boughtQty < scheme.TriggerQty)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
$"Requires {scheme.TriggerQty} of the trigger item; order has {boughtQty}.", null, null, null); $"Requires {scheme.TriggerQty} of the trigger item; ShopPO has {boughtQty}.", null, null, null);
var multiplier = boughtQty / scheme.TriggerQty.Value; var multiplier = boughtQty / scheme.TriggerQty.Value;
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null, return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
@@ -332,11 +332,11 @@ public class SchemeService(
"Scheme has no bundle components or free-goods payout configured.", null, null, null); "Scheme has no bundle components or free-goods payout configured.", null, null, null);
var missing = scheme.BundleComponents var missing = scheme.BundleComponents
.Where(c => orderItems.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty) .Where(c => items.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty)
.ToList(); .ToList();
if (missing.Count > 0) if (missing.Count > 0)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Order is missing required bundle components.", null, null, null); "ShopPO is missing required bundle components.", null, null, null);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null, return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
null, scheme.FK_FreeItemId, scheme.FreeQty); null, scheme.FK_FreeItemId, scheme.FreeQty);
@@ -346,7 +346,7 @@ public class SchemeService(
if (string.IsNullOrEmpty(scheme.RuleConfig)) if (string.IsNullOrEmpty(scheme.RuleConfig))
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null); 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 cfg = JsonSerializer.Deserialize<FlatPercentConfig>(scheme.RuleConfig, JsonOptions)!;
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice); var orderTotal = items.Sum(i => i.Quantity * i.UnitPrice);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null, return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null); Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null);
} }
@@ -355,12 +355,12 @@ public class SchemeService(
if (string.IsNullOrEmpty(scheme.RuleConfig)) if (string.IsNullOrEmpty(scheme.RuleConfig))
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null); 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 cfg = JsonSerializer.Deserialize<SlabDiscountConfig>(scheme.RuleConfig, JsonOptions)!;
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice); var orderTotal = items.Sum(i => i.Quantity * i.UnitPrice);
var tier = cfg.Tiers.Where(t => orderTotal >= t.MinOrderAmount) var tier = cfg.Tiers.Where(t => orderTotal >= t.MinOrderAmount)
.OrderByDescending(t => t.MinOrderAmount).FirstOrDefault(); .OrderByDescending(t => t.MinOrderAmount).FirstOrDefault();
if (tier is null) if (tier is null)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Order total does not meet any slab threshold.", null, null, null); "ShopPO total does not meet any slab threshold.", null, null, null);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null, return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
Math.Round(orderTotal * tier.DiscountPercent / 100m, 2), null, null); Math.Round(orderTotal * tier.DiscountPercent / 100m, 2), null, null);
@@ -370,32 +370,32 @@ public class SchemeService(
} }
} }
// ── applySchemeDiscount(orderId, schemeId) ─────────────────────────────── // ── applySchemeDiscount(shopPOId, schemeId) ───────────────────────────────
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid orderId, Guid schemeId, CancellationToken ct = default) public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid shopPOId, Guid schemeId, CancellationToken ct = default)
{ {
var scheme = await repo.GetByIdAsync(schemeId); var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null; if (scheme is null || !scheme.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId); var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (order is null || !order.IsActive) return null; if (shopPO is null || !shopPO.IsActive) return null;
if (await repo.HasRedemptionAsync(schemeId, orderId)) if (await repo.HasRedemptionAsync(schemeId, shopPOId))
throw new InvalidOperationException("This scheme has already been redeemed against this order."); throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId); var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
var eligibility = EvaluateRule(scheme, orderItems); var eligibility = EvaluateRule(scheme, items);
if (!eligibility.Eligible) if (!eligibility.Eligible)
throw new InvalidOperationException(eligibility.Reason ?? "Order is not eligible for this scheme."); throw new InvalidOperationException(eligibility.Reason ?? "ShopPO is not eligible for this scheme.");
var redemption = new SchemeRedemption var redemption = new SchemeRedemption
{ {
FK_SchemeId = schemeId, FK_SchemeId = schemeId,
FK_OrderId = orderId, FK_ShopPOId = shopPOId,
DiscountAmount = eligibility.DiscountAmount DiscountAmount = eligibility.DiscountAmount
}; };
var newOrderItems = new List<OrderItem>(); var newItems = new List<ShopPOItem>();
if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0) if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0)
{ {
@@ -406,9 +406,9 @@ public class SchemeService(
if (availableQty < eligibility.FreeQty.Value) if (availableQty < eligibility.FreeQty.Value)
throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme."); throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme.");
newOrderItems.Add(new OrderItem newItems.Add(new ShopPOItem
{ {
FK_OrderId = orderId, FK_ShopPOId = shopPOId,
FK_ItemId = freeItem.Id, FK_ItemId = freeItem.Id,
Quantity = (int)eligibility.FreeQty.Value, Quantity = (int)eligibility.FreeQty.Value,
UnitPrice = 0m UnitPrice = 0m
@@ -422,7 +422,7 @@ public class SchemeService(
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems); if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
await repo.AddRedemptionAsync(redemption); await repo.AddRedemptionAsync(redemption);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
@@ -457,8 +457,8 @@ public class SchemeService(
var spend = await GetSpendAsync(schemeId); var spend = await GetSpendAsync(schemeId);
var actualSales = redemptions var actualSales = redemptions
.Where(r => r.Order is not null) .Where(r => r.ShopPO is not null)
.Select(r => r.Order!) .Select(r => r.ShopPO!)
.Distinct() .Distinct()
.Sum(o => o.TotalAmount); .Sum(o => o.TotalAmount);
@@ -0,0 +1,18 @@
using Distribution.Models;
namespace Distribution.Features.ShopPO;
public interface IShopPORepository
{
Task<Models.ShopPO?> GetByIdAsync(Guid id);
Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed);
Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId);
Task<List<Models.ShopPOItem>> GetActiveItemsByShopPOIdAsync(Guid shopPOId);
Task<List<Models.ShopPO>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.ShopPO entity);
void Update(Models.ShopPO entity);
void Remove(Models.ShopPO entity);
Task AddItemsAsync(IEnumerable<Models.ShopPOItem> items);
Task ReplaceItemsAsync(Guid shopPOId, List<Models.ShopPOItem> newItems);
}
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ShopPO;
[ApiController]
[Route("api/shop-pos")]
public class ShopPOController(ShopPOService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? agentId,
[FromQuery] Guid? routeId,
[FromQuery] Guid? shopId,
[FromQuery] bool? confirmed,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,24 @@
using Distribution.Models;
namespace Distribution.Features.ShopPO;
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items);
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
public record ShopPOResponse(
Guid Id,
Guid StoreId,
string StoreName,
Guid RaisedById,
Guid? InvoiceId,
decimal TotalAmount,
ShopPOStatus Status,
DateTime? ConfirmedAt,
Guid? ConfirmedBy,
DateTime? FulfilledAt,
List<ShopPOItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,91 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ShopPO;
public class ShopPORepository(AppDBContext context) : IShopPORepository
{
public async Task<Models.ShopPO?> GetByIdAsync(Guid id) =>
await context.ShopPOs
.Include(p => p.Store)
.Include(p => p.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
public async Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize) =>
await context.ShopPOs
.Include(p => p.Store)
.Where(p => p.IsActive
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (shopId == null || p.FK_StoreId == shopId)
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)))
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed) =>
await context.ShopPOs
.Include(p => p.Store)
.CountAsync(p => p.IsActive
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (shopId == null || p.FK_StoreId == shopId)
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)));
public async Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId) =>
await context.ShopPOs
.Include(p => p.Store)
.Include(p => p.Items.Where(i => i.IsActive))
.Where(p => p.IsActive
&& p.ConfirmedAt != null
&& p.InvoiceId == null
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (shopId == null || p.FK_StoreId == shopId))
.ToListAsync();
public async Task<List<Models.ShopPOItem>> GetActiveItemsByShopPOIdAsync(Guid shopPOId) =>
await context.ShopPOItems
.Include(i => i.Item)
.Where(i => i.FK_ShopPOId == shopPOId && i.IsActive)
.ToListAsync();
public async Task<List<Models.ShopPO>> GetByStoreIdAsync(Guid storeId) =>
await context.ShopPOs
.Include(p => p.Items.Where(i => i.IsActive))
.Where(p => p.FK_StoreId == storeId && p.IsActive)
.ToListAsync();
public async Task AddAsync(Models.ShopPO entity) =>
await context.ShopPOs.AddAsync(entity);
public void Update(Models.ShopPO entity) =>
context.ShopPOs.Update(entity);
public void Remove(Models.ShopPO entity)
{
entity.IsActive = false;
context.ShopPOs.Update(entity);
}
public async Task AddItemsAsync(IEnumerable<Models.ShopPOItem> items) =>
await context.ShopPOItems.AddRangeAsync(items);
public async Task ReplaceItemsAsync(Guid shopPOId, List<Models.ShopPOItem> newItems)
{
var current = await context.ShopPOItems
.Where(i => i.FK_ShopPOId == shopPOId && i.IsActive)
.ToListAsync();
foreach (var item in current)
{
item.IsActive = false;
context.ShopPOItems.Update(item);
}
await context.ShopPOItems.AddRangeAsync(newItems);
}
}
@@ -0,0 +1,32 @@
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.ShopPO;
public class ShopPOService(
IShopPORepository repo,
IUnitOfWork uow)
{
private static ShopPOResponse ToResponse(Models.ShopPO p) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
p.Items.Where(i => i.IsActive).Select(i =>
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
p.IsActive, p.CreatedAt);
public async Task<PagedResponse<ShopPOResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, confirmed);
var items = await repo.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
return new PagedResponse<ShopPOResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<ShopPOResponse?> GetByIdAsync(Guid id)
{
var po = await repo.GetByIdAsync(id);
return po is null ? null : ToResponse(po);
}
}
@@ -12,5 +12,5 @@ public interface IStoreRepository
Task AddAsync(Models.Store entity); Task AddAsync(Models.Store entity);
void Update(Models.Store entity); void Update(Models.Store entity);
void Remove(Models.Store entity); void Remove(Models.Store entity);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive); Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
} }
@@ -48,8 +48,8 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
context.Stores.Update(entity); context.Stores.Update(entity);
} }
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive) => public async Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive) =>
await context.Orders await context.ShopPOs
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive) .Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive)
.ToListAsync(); .ToListAsync();
} }
+1 -1
View File
@@ -268,6 +268,6 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
periodStart.ToString("yyyy-MM"), periodStart.ToString("yyyy-MM"),
orders.Count, orders.Count,
orders.Sum(o => o.TotalAmount), orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate)); orders.Count == 0 ? null : orders.Max(o => o.CreatedAt));
} }
} }
@@ -0,0 +1,8 @@
namespace Distribution.Features.SysConfig;
public interface ISysConfigRepository
{
Task<Models.SysConfig?> GetByKeyAsync(string key);
Task<List<Models.SysConfig>> GetAllAsync();
Task UpsertAsync(Models.SysConfig entity);
}
@@ -0,0 +1,53 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SysConfig;
[ApiController]
[Route("api/sys-config")]
public class SysConfigController(SysConfigService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
// Typed shortcut — must be declared before {key} to take precedence
[HttpGet("invoice-creation-mode")]
public async Task<IActionResult> GetInvoiceCreationMode()
{
var mode = await service.GetInvoiceCreationModeAsync();
return Ok(new { Mode = mode });
}
[HttpPut("invoice-creation-mode")]
public async Task<IActionResult> SetInvoiceCreationMode(SetInvoiceCreationModeRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.SetAsync(SysConfigService.KeyInvoiceCreationMode, request.Mode.ToString(), userId, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("{key}")]
public async Task<IActionResult> GetByKey(string key)
{
var result = await service.GetByKeyAsync(key);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{key}")]
public async Task<IActionResult> Set(string key, SetSysConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.SetAsync(key, request.Value, userId, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,9 @@
using Distribution.Models;
namespace Distribution.Features.SysConfig;
public record SysConfigResponse(string Key, string Value, DateTime UpdatedAt, Guid? UpdatedBy);
public record SetSysConfigRequest(string Value);
public record SetInvoiceCreationModeRequest(InvoiceCreationMode Mode);
@@ -0,0 +1,29 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.SysConfig;
public class SysConfigRepository(AppDBContext context) : ISysConfigRepository
{
public async Task<Models.SysConfig?> GetByKeyAsync(string key) =>
await context.SysConfigs.FindAsync(key);
public async Task<List<Models.SysConfig>> GetAllAsync() =>
await context.SysConfigs.OrderBy(c => c.Key).ToListAsync();
public async Task UpsertAsync(Models.SysConfig entity)
{
var existing = await context.SysConfigs.FindAsync(entity.Key);
if (existing is null)
{
await context.SysConfigs.AddAsync(entity);
}
else
{
existing.Value = entity.Value;
existing.UpdatedAt = entity.UpdatedAt;
existing.UpdatedBy = entity.UpdatedBy;
context.SysConfigs.Update(existing);
}
}
}
@@ -0,0 +1,67 @@
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.SysConfig;
public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
{
public const string KeyInvoiceCreationMode = "InvoiceCreationMode";
public async Task<List<SysConfigResponse>> GetAllAsync()
{
var configs = await repo.GetAllAsync();
return configs.Select(ToResponse).ToList();
}
public async Task<SysConfigResponse?> GetByKeyAsync(string key)
{
var config = await repo.GetByKeyAsync(key);
return config is null ? null : ToResponse(config);
}
public async Task<SysConfigResponse> SetAsync(string key, string value, Guid userId, CancellationToken ct = default)
{
ValidateValue(key, value);
var config = new Models.SysConfig
{
Key = key,
Value = value,
UpdatedAt = DateTime.Now,
UpdatedBy = userId
};
await uow.BeginAsync(ct);
try
{
await repo.UpsertAsync(config);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByKeyAsync(key);
return ToResponse(saved!);
}
/// <summary>
/// Returns the current InvoiceCreationMode, defaulting to ModeB if not configured.
/// ModeA = invoice auto-created on ShopPO confirmation.
/// ModeB = invoice created as a separate explicit step.
/// </summary>
public async Task<InvoiceCreationMode> GetInvoiceCreationModeAsync()
{
var config = await repo.GetByKeyAsync(KeyInvoiceCreationMode);
if (config is null) return InvoiceCreationMode.ModeB;
return Enum.TryParse<InvoiceCreationMode>(config.Value, out var mode) ? mode : InvoiceCreationMode.ModeB;
}
private static void ValidateValue(string key, string value)
{
if (key == KeyInvoiceCreationMode && !Enum.TryParse<InvoiceCreationMode>(value, out _))
throw new InvalidOperationException(
$"Invalid value '{value}' for '{key}'. Allowed values: {string.Join(", ", Enum.GetNames<InvoiceCreationMode>())}.");
}
private static SysConfigResponse ToResponse(Models.SysConfig c) =>
new(c.Key, c.Value, c.UpdatedAt, c.UpdatedBy);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,944 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class RefactorSalesToShopPOAndInvoice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
DropForeignKeyIfExists(migrationBuilder, "DeliveryRuns", "FK_DeliveryRuns_Routes_FK_RouteId");
DropForeignKeyIfExists(migrationBuilder, "DeliveryRuns", "FK_DeliveryRuns_Warehouses_FK_SourceWarehouseId");
DropForeignKeyIfExists(migrationBuilder, "DeliveryTasks", "FK_DeliveryTasks_Orders_FK_OrderId");
DropForeignKeyIfExists(migrationBuilder, "LoadingSheetItems", "FK_LoadingSheetItems_LoadingSheets_FK_LoadingSheetId");
DropForeignKeyIfExists(migrationBuilder, "LoadingSheets", "FK_LoadingSheets_Warehouses_FK_SourceWarehouseId");
DropForeignKeyIfExists(migrationBuilder, "Payments", "FK_Payments_Sales_FK_SaleId");
DropForeignKeyIfExists(migrationBuilder, "Payments", "FK_Payments_Stores_FK_StoreId");
DropForeignKeyIfExists(migrationBuilder, "SaleReturns", "FK_SaleReturns_Orders_FK_OrderId");
DropForeignKeyIfExists(migrationBuilder, "SchemeRedemptions", "FK_SchemeRedemptions_Orders_FK_OrderId");
migrationBuilder.DropTable(
name: "OrderItems");
migrationBuilder.DropTable(
name: "Orders");
migrationBuilder.DropTable(
name: "Sales");
DropIndexIfExists(migrationBuilder, "Payments", "IX_Payments_FK_SaleId");
DropIndexIfExists(migrationBuilder, "LoadingSheets", "IX_LoadingSheets_FK_SourceWarehouseId");
DropIndexIfExists(migrationBuilder, "DeliveryRuns", "IX_DeliveryRuns_FK_RouteId");
DropIndexIfExists(migrationBuilder, "DeliveryRuns", "IX_DeliveryRuns_FK_SourceWarehouseId");
DropColumnIfExists(migrationBuilder, "Payments", "FK_SaleId");
DropColumnIfExists(migrationBuilder, "LoadingSheets", "DispatchConfirmedAt");
DropColumnIfExists(migrationBuilder, "LoadingSheets", "DispatchConfirmedBy");
DropColumnIfExists(migrationBuilder, "LoadingSheetItems", "QuantityReceived");
DropColumnIfExists(migrationBuilder, "LoadingSheetItems", "QuantityResolved");
DropColumnIfExists(migrationBuilder, "DeliveryRuns", "ActivatedAt");
DropColumnIfExists(migrationBuilder, "DeliveryRuns", "FK_RouteId");
DropColumnIfExists(migrationBuilder, "DeliveryRuns", "FK_SourceWarehouseId");
DropColumnIfExists(migrationBuilder, "DeliveryRuns", "RideType");
migrationBuilder.RenameColumn(
name: "FK_OrderId",
table: "SchemeRedemptions",
newName: "FK_ShopPOId");
migrationBuilder.RenameIndex(
name: "IX_SchemeRedemptions_FK_OrderId",
table: "SchemeRedemptions",
newName: "IX_SchemeRedemptions_FK_ShopPOId");
migrationBuilder.RenameColumn(
name: "FK_OrderId",
table: "SaleReturns",
newName: "FK_ShopPOId");
migrationBuilder.RenameIndex(
name: "IX_SaleReturns_FK_OrderId",
table: "SaleReturns",
newName: "IX_SaleReturns_FK_ShopPOId");
migrationBuilder.RenameColumn(
name: "FK_StoreId",
table: "Payments",
newName: "FK_InvoiceId");
migrationBuilder.RenameIndex(
name: "IX_Payments_FK_StoreId",
table: "Payments",
newName: "IX_Payments_FK_InvoiceId");
migrationBuilder.RenameColumn(
name: "ReceiptConfirmedBy",
table: "LoadingSheets",
newName: "ConfirmedBy");
migrationBuilder.RenameColumn(
name: "ReceiptConfirmedAt",
table: "LoadingSheets",
newName: "ConfirmedAt");
migrationBuilder.RenameColumn(
name: "FK_SourceWarehouseId",
table: "LoadingSheets",
newName: "PopulatedBy");
migrationBuilder.RenameColumn(
name: "FK_OrderId",
table: "DeliveryTasks",
newName: "FK_ShopPOId");
migrationBuilder.RenameIndex(
name: "IX_DeliveryTasks_FK_OrderId",
table: "DeliveryTasks",
newName: "IX_DeliveryTasks_FK_ShopPOId");
migrationBuilder.AddColumn<DateTime>(
name: "PopulatedAt",
table: "LoadingSheets",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "LoadingSheetItems",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<Guid>(
name: "FK_ShopPOItemId",
table: "LoadingSheetItems",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<Guid>(
name: "FK_WarehouseId",
table: "LoadingSheetItems",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<bool>(
name: "IsActive",
table: "LoadingSheetItems",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "ShopPOs",
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"),
RaisedById = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
InvoiceId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ConfirmedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ConfirmedBy = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FulfilledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopPOs", x => x.Id);
table.ForeignKey(
name: "FK_ShopPOs_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Invoices",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ShopPOId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Status = table.Column<int>(type: "int", nullable: false),
PaymentStatus = table.Column<int>(type: "int", nullable: false),
CreatedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
PrintedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PrintedBy = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FinalizedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
FinalizedBy = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", 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_Invoices", x => x.Id);
table.ForeignKey(
name: "FK_Invoices_ShopPOs_FK_ShopPOId",
column: x => x.FK_ShopPOId,
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "LoadingSheetOrders",
columns: table => new
{
FK_LoadingSheetId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ShopPOId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci")
},
constraints: table =>
{
table.PrimaryKey("PK_LoadingSheetOrders", x => new { x.FK_LoadingSheetId, x.FK_ShopPOId });
table.ForeignKey(
name: "FK_LoadingSheetOrders_LoadingSheets_FK_LoadingSheetId",
column: x => x.FK_LoadingSheetId,
principalTable: "LoadingSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_LoadingSheetOrders_ShopPOs_FK_ShopPOId",
column: x => x.FK_ShopPOId,
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "ShopPOItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ShopPOId = 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),
QuantityFulfilled = table.Column<int>(type: "int", nullable: false),
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", 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_ShopPOItems", x => x.Id);
table.ForeignKey(
name: "FK_ShopPOItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ShopPOItems_ShopPOs_FK_ShopPOId",
column: x => x.FK_ShopPOId,
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.UpdateData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000001"),
column: "Status",
value: 2);
migrationBuilder.UpdateData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000002"),
column: "Status",
value: 0);
migrationBuilder.UpdateData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000001"),
columns: new[] { "CreatedAt", "FK_ShopPOItemId", "FK_WarehouseId", "IsActive" },
values: new object[] { new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), null, new Guid("27000000-0000-0000-0000-000000000001"), true });
migrationBuilder.UpdateData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000002"),
columns: new[] { "CreatedAt", "FK_ShopPOItemId", "FK_WarehouseId", "IsActive" },
values: new object[] { new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), null, new Guid("27000000-0000-0000-0000-000000000001"), true });
migrationBuilder.UpdateData(
table: "LoadingSheets",
keyColumn: "Id",
keyValue: new Guid("35000000-0000-0000-0000-000000000001"),
columns: new[] { "ConfirmedAt", "ConfirmedBy", "PopulatedAt", "PopulatedBy", "Status" },
values: new object[] { new DateTime(2026, 8, 25, 7, 45, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), 1 });
migrationBuilder.UpdateData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000001"),
column: "FK_InvoiceId",
value: new Guid("34500000-0000-0000-0000-000000000001"));
migrationBuilder.InsertData(
table: "ShopPOs",
columns: new[] { "Id", "ConfirmedAt", "ConfirmedBy", "CreatedAt", "FK_StoreId", "FulfilledAt", "InvoiceId", "IsActive", "RaisedById", "Status", "TotalAmount" },
values: new object[,]
{
{ new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 8, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("26000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34500000-0000-0000-0000-000000000001"), true, new Guid("29000000-0000-0000-0000-000000000002"), 2, 3000m },
{ new Guid("33000000-0000-0000-0000-000000000002"), null, null, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("26000000-0000-0000-0000-000000000005"), null, null, true, new Guid("29000000-0000-0000-0000-000000000003"), 0, 1200m }
});
migrationBuilder.InsertData(
table: "Invoices",
columns: new[] { "Id", "CreatedAt", "CreatedBy", "FK_ShopPOId", "FinalizedAt", "FinalizedBy", "IsActive", "PaymentStatus", "PrintedAt", "PrintedBy", "Status", "TotalAmount" },
values: new object[] { new Guid("34500000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 8, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), true, 2, new DateTime(2026, 8, 25, 8, 45, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), 2, 3000m });
migrationBuilder.InsertData(
table: "ShopPOItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_ShopPOId", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
values: new object[,]
{
{ new Guid("34000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
{ new Guid("34000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), true, 24, 24, 50m },
{ new Guid("34000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), true, 10, 0, 120m }
});
migrationBuilder.CreateIndex(
name: "IX_LoadingSheetItems_FK_ShopPOItemId",
table: "LoadingSheetItems",
column: "FK_ShopPOItemId");
migrationBuilder.CreateIndex(
name: "IX_LoadingSheetItems_FK_WarehouseId",
table: "LoadingSheetItems",
column: "FK_WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_Invoices_FK_ShopPOId",
table: "Invoices",
column: "FK_ShopPOId");
migrationBuilder.CreateIndex(
name: "IX_LoadingSheetOrders_FK_ShopPOId",
table: "LoadingSheetOrders",
column: "FK_ShopPOId");
migrationBuilder.CreateIndex(
name: "IX_ShopPOItems_FK_ItemId",
table: "ShopPOItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_ShopPOItems_FK_ShopPOId",
table: "ShopPOItems",
column: "FK_ShopPOId");
migrationBuilder.CreateIndex(
name: "IX_ShopPOs_FK_StoreId",
table: "ShopPOs",
column: "FK_StoreId");
migrationBuilder.AddForeignKey(
name: "FK_DeliveryTasks_ShopPOs_FK_ShopPOId",
table: "DeliveryTasks",
column: "FK_ShopPOId",
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_LoadingSheetItems_LoadingSheets_FK_LoadingSheetId",
table: "LoadingSheetItems",
column: "FK_LoadingSheetId",
principalTable: "LoadingSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_LoadingSheetItems_ShopPOItems_FK_ShopPOItemId",
table: "LoadingSheetItems",
column: "FK_ShopPOItemId",
principalTable: "ShopPOItems",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_LoadingSheetItems_Warehouses_FK_WarehouseId",
table: "LoadingSheetItems",
column: "FK_WarehouseId",
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Payments_Invoices_FK_InvoiceId",
table: "Payments",
column: "FK_InvoiceId",
principalTable: "Invoices",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_SaleReturns_ShopPOs_FK_ShopPOId",
table: "SaleReturns",
column: "FK_ShopPOId",
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_SchemeRedemptions_ShopPOs_FK_ShopPOId",
table: "SchemeRedemptions",
column: "FK_ShopPOId",
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_DeliveryTasks_ShopPOs_FK_ShopPOId",
table: "DeliveryTasks");
migrationBuilder.DropForeignKey(
name: "FK_LoadingSheetItems_LoadingSheets_FK_LoadingSheetId",
table: "LoadingSheetItems");
migrationBuilder.DropForeignKey(
name: "FK_LoadingSheetItems_ShopPOItems_FK_ShopPOItemId",
table: "LoadingSheetItems");
migrationBuilder.DropForeignKey(
name: "FK_LoadingSheetItems_Warehouses_FK_WarehouseId",
table: "LoadingSheetItems");
migrationBuilder.DropForeignKey(
name: "FK_Payments_Invoices_FK_InvoiceId",
table: "Payments");
migrationBuilder.DropForeignKey(
name: "FK_SaleReturns_ShopPOs_FK_ShopPOId",
table: "SaleReturns");
migrationBuilder.DropForeignKey(
name: "FK_SchemeRedemptions_ShopPOs_FK_ShopPOId",
table: "SchemeRedemptions");
migrationBuilder.DropTable(
name: "Invoices");
migrationBuilder.DropTable(
name: "LoadingSheetOrders");
migrationBuilder.DropTable(
name: "ShopPOItems");
migrationBuilder.DropTable(
name: "ShopPOs");
migrationBuilder.DropIndex(
name: "IX_LoadingSheetItems_FK_ShopPOItemId",
table: "LoadingSheetItems");
migrationBuilder.DropIndex(
name: "IX_LoadingSheetItems_FK_WarehouseId",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "PopulatedAt",
table: "LoadingSheets");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "FK_ShopPOItemId",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "FK_WarehouseId",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "IsActive",
table: "LoadingSheetItems");
migrationBuilder.RenameColumn(
name: "FK_ShopPOId",
table: "SchemeRedemptions",
newName: "FK_OrderId");
migrationBuilder.RenameIndex(
name: "IX_SchemeRedemptions_FK_ShopPOId",
table: "SchemeRedemptions",
newName: "IX_SchemeRedemptions_FK_OrderId");
migrationBuilder.RenameColumn(
name: "FK_ShopPOId",
table: "SaleReturns",
newName: "FK_OrderId");
migrationBuilder.RenameIndex(
name: "IX_SaleReturns_FK_ShopPOId",
table: "SaleReturns",
newName: "IX_SaleReturns_FK_OrderId");
migrationBuilder.RenameColumn(
name: "FK_InvoiceId",
table: "Payments",
newName: "FK_StoreId");
migrationBuilder.RenameIndex(
name: "IX_Payments_FK_InvoiceId",
table: "Payments",
newName: "IX_Payments_FK_StoreId");
migrationBuilder.RenameColumn(
name: "PopulatedBy",
table: "LoadingSheets",
newName: "FK_SourceWarehouseId");
migrationBuilder.RenameColumn(
name: "ConfirmedBy",
table: "LoadingSheets",
newName: "ReceiptConfirmedBy");
migrationBuilder.RenameColumn(
name: "ConfirmedAt",
table: "LoadingSheets",
newName: "ReceiptConfirmedAt");
migrationBuilder.RenameColumn(
name: "FK_ShopPOId",
table: "DeliveryTasks",
newName: "FK_OrderId");
migrationBuilder.RenameIndex(
name: "IX_DeliveryTasks_FK_ShopPOId",
table: "DeliveryTasks",
newName: "IX_DeliveryTasks_FK_OrderId");
migrationBuilder.AddColumn<Guid>(
name: "FK_SaleId",
table: "Payments",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<DateTime>(
name: "DispatchConfirmedAt",
table: "LoadingSheets",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "DispatchConfirmedBy",
table: "LoadingSheets",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<decimal>(
name: "QuantityReceived",
table: "LoadingSheetItems",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "QuantityResolved",
table: "LoadingSheetItems",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ActivatedAt",
table: "DeliveryRuns",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "FK_RouteId",
table: "DeliveryRuns",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<Guid>(
name: "FK_SourceWarehouseId",
table: "DeliveryRuns",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<int>(
name: "RideType",
table: "DeliveryRuns",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "Sales",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
FulfillImmediately = table.Column<bool>(type: "tinyint(1)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
PaymentStatus = table.Column<int>(type: "int", nullable: false),
SaleDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sales", x => x.Id);
table.ForeignKey(
name: "FK_Sales_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Sales_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Sales_Users_CreatedByUserId",
column: x => x.CreatedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Orders",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SaleId = 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"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
FulfilledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
OrderDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Orders", x => x.Id);
table.ForeignKey(
name: "FK_Orders_Sales_FK_SaleId",
column: x => x.FK_SaleId,
principalTable: "Sales",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Orders_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "OrderItems",
columns: table => new
{
FK_OrderId = 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"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false),
QuantityFulfilled = table.Column<int>(type: "int", nullable: false),
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OrderItems", x => new { x.FK_OrderId, x.FK_ItemId });
table.ForeignKey(
name: "FK_OrderItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_OrderItems_Orders_FK_OrderId",
column: x => x.FK_OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.UpdateData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000001"),
columns: new[] { "ActivatedAt", "FK_RouteId", "FK_SourceWarehouseId", "RideType", "Status" },
values: new object[] { new DateTime(2026, 8, 25, 8, 15, 0, 0, DateTimeKind.Unspecified), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 1, 5 });
migrationBuilder.UpdateData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000002"),
columns: new[] { "ActivatedAt", "FK_RouteId", "FK_SourceWarehouseId", "RideType", "Status" },
values: new object[] { new DateTime(2026, 8, 30, 9, 20, 0, 0, DateTimeKind.Unspecified), new Guid("25000000-0000-0000-0000-000000000003"), new Guid("27000000-0000-0000-0000-000000000002"), 0, 2 });
migrationBuilder.UpdateData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000001"),
columns: new[] { "QuantityReceived", "QuantityResolved" },
values: new object[] { 50m, null });
migrationBuilder.UpdateData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000002"),
columns: new[] { "QuantityReceived", "QuantityResolved" },
values: new object[] { 30m, null });
migrationBuilder.UpdateData(
table: "LoadingSheets",
keyColumn: "Id",
keyValue: new Guid("35000000-0000-0000-0000-000000000001"),
columns: new[] { "DispatchConfirmedAt", "DispatchConfirmedBy", "FK_SourceWarehouseId", "ReceiptConfirmedAt", "ReceiptConfirmedBy", "Status" },
values: new object[] { new DateTime(2026, 8, 25, 7, 45, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 14, 5, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000004"), 2 });
migrationBuilder.UpdateData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000001"),
columns: new[] { "FK_SaleId", "FK_StoreId" },
values: new object[] { new Guid("32000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001") });
migrationBuilder.InsertData(
table: "Sales",
columns: new[] { "Id", "CompletedAt", "CreatedAt", "CreatedByUserId", "FK_RideId", "FK_StoreId", "FulfillImmediately", "IsActive", "PaymentStatus", "SaleDate", "Status", "TotalAmount" },
values: new object[,]
{
{ new Guid("32000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("31000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), false, true, 2, new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 1, 3000m },
{ new Guid("32000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new Guid("31000000-0000-0000-0000-000000000002"), new Guid("26000000-0000-0000-0000-000000000005"), false, true, 0, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), 0, 1200m }
});
migrationBuilder.InsertData(
table: "Orders",
columns: new[] { "Id", "CreatedAt", "FK_SaleId", "FK_StoreId", "FulfilledAt", "IsActive", "OrderDate", "Status", "TotalAmount" },
values: new object[,]
{
{ new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified), true, new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 2, 3000m },
{ new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000002"), new Guid("26000000-0000-0000-0000-000000000005"), null, true, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), 0, 1200m }
});
migrationBuilder.InsertData(
table: "OrderItems",
columns: new[] { "FK_ItemId", "FK_OrderId", "CreatedAt", "Id", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
values: new object[,]
{
{ new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
{ new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000002"), true, 24, 24, 50m },
{ new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000003"), true, 10, 0, 120m }
});
migrationBuilder.CreateIndex(
name: "IX_Payments_FK_SaleId",
table: "Payments",
column: "FK_SaleId");
migrationBuilder.CreateIndex(
name: "IX_LoadingSheets_FK_SourceWarehouseId",
table: "LoadingSheets",
column: "FK_SourceWarehouseId");
migrationBuilder.CreateIndex(
name: "IX_DeliveryRuns_FK_RouteId",
table: "DeliveryRuns",
column: "FK_RouteId");
migrationBuilder.CreateIndex(
name: "IX_DeliveryRuns_FK_SourceWarehouseId",
table: "DeliveryRuns",
column: "FK_SourceWarehouseId");
migrationBuilder.CreateIndex(
name: "IX_OrderItems_FK_ItemId",
table: "OrderItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_Orders_FK_SaleId",
table: "Orders",
column: "FK_SaleId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Orders_FK_StoreId",
table: "Orders",
column: "FK_StoreId");
migrationBuilder.CreateIndex(
name: "IX_Sales_CreatedByUserId",
table: "Sales",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Sales_FK_RideId",
table: "Sales",
column: "FK_RideId");
migrationBuilder.CreateIndex(
name: "IX_Sales_FK_StoreId",
table: "Sales",
column: "FK_StoreId");
migrationBuilder.AddForeignKey(
name: "FK_DeliveryRuns_Routes_FK_RouteId",
table: "DeliveryRuns",
column: "FK_RouteId",
principalTable: "Routes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DeliveryRuns_Warehouses_FK_SourceWarehouseId",
table: "DeliveryRuns",
column: "FK_SourceWarehouseId",
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_DeliveryTasks_Orders_FK_OrderId",
table: "DeliveryTasks",
column: "FK_OrderId",
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_LoadingSheetItems_LoadingSheets_FK_LoadingSheetId",
table: "LoadingSheetItems",
column: "FK_LoadingSheetId",
principalTable: "LoadingSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_LoadingSheets_Warehouses_FK_SourceWarehouseId",
table: "LoadingSheets",
column: "FK_SourceWarehouseId",
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Payments_Sales_FK_SaleId",
table: "Payments",
column: "FK_SaleId",
principalTable: "Sales",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Payments_Stores_FK_StoreId",
table: "Payments",
column: "FK_StoreId",
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_SaleReturns_Orders_FK_OrderId",
table: "SaleReturns",
column: "FK_OrderId",
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_SchemeRedemptions_Orders_FK_OrderId",
table: "SchemeRedemptions",
column: "FK_OrderId",
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
private static void DropForeignKeyIfExists(MigrationBuilder migrationBuilder, string tableName, string constraintName)
{
migrationBuilder.Sql($"ALTER TABLE `{tableName}` DROP FOREIGN KEY IF EXISTS `{constraintName}`;");
}
private static void DropIndexIfExists(MigrationBuilder migrationBuilder, string tableName, string indexName)
{
migrationBuilder.Sql($"""
SET @sql = IF(
EXISTS(
SELECT 1
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{tableName}'
AND INDEX_NAME = '{indexName}'
),
'DROP INDEX `{indexName}` ON `{tableName}`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
""");
}
private static void DropColumnIfExists(MigrationBuilder migrationBuilder, string tableName, string columnName)
{
migrationBuilder.Sql($"""
SET @sql = IF(
EXISTS(
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{tableName}'
AND COLUMN_NAME = '{columnName}'
),
'ALTER TABLE `{tableName}` DROP COLUMN `{columnName}`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddSysConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SysConfigs",
columns: table => new
{
Key = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Value = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedBy = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci")
},
constraints: table =>
{
table.PrimaryKey("PK_SysConfigs", x => x.Key);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.InsertData(
table: "SysConfigs",
columns: new[] { "Key", "UpdatedAt", "UpdatedBy", "Value" },
values: new object[] { "InvoiceCreationMode", new DateTime(2026, 9, 17, 0, 0, 0, 0, DateTimeKind.Unspecified), null, "ModeB" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SysConfigs");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class RemoveRideCancelRequest : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RideCancelRequests");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RideCancelRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ApprovedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ApprovedBy = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
RequestedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
RequestedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Status = table.Column<int>(type: "int", nullable: false),
TypeCode = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RideCancelRequests", x => x.Id);
table.ForeignKey(
name: "FK_RideCancelRequests_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.InsertData(
table: "RideCancelRequests",
columns: new[] { "Id", "ApprovedAt", "ApprovedBy", "FK_RideId", "Reason", "RequestedAt", "RequestedBy", "Status", "TypeCode" },
values: new object[] { new Guid("65000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 30, 9, 15, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("31000000-0000-0000-0000-000000000002"), "Reported minor engine issue, resolved after inspection", new DateTime(2026, 8, 30, 9, 10, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000005"), 2, 0 });
migrationBuilder.CreateIndex(
name: "IX_RideCancelRequests_FK_RideId",
table: "RideCancelRequests",
column: "FK_RideId");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddPaymentChequeAndEntryMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ChequeDate",
table: "Payments",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ChequeNumber",
table: "Payments",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<int>(
name: "EntryMode",
table: "Payments",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<Guid>(
name: "RecordedBy",
table: "Payments",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.UpdateData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000001"),
columns: new[] { "ChequeDate", "ChequeNumber", "EntryMode", "RecordedBy" },
values: new object[] { null, null, 0, null });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ChequeDate",
table: "Payments");
migrationBuilder.DropColumn(
name: "ChequeNumber",
table: "Payments");
migrationBuilder.DropColumn(
name: "EntryMode",
table: "Payments");
migrationBuilder.DropColumn(
name: "RecordedBy",
table: "Payments");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class SeedInvoice2AndMorePayments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "ShopPOs",
columns: new[] { "Id", "ConfirmedAt", "ConfirmedBy", "CreatedAt", "FK_StoreId", "FulfilledAt", "InvoiceId", "IsActive", "RaisedById", "Status", "TotalAmount" },
values: new object[] { new Guid("33000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 31, 8, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("26000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 31, 13, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34500000-0000-0000-0000-000000000002"), true, new Guid("29000000-0000-0000-0000-000000000003"), 2, 2400m });
migrationBuilder.InsertData(
table: "Invoices",
columns: new[] { "Id", "CreatedAt", "CreatedBy", "FK_ShopPOId", "FinalizedAt", "FinalizedBy", "IsActive", "PaymentStatus", "PrintedAt", "PrintedBy", "Status", "TotalAmount" },
values: new object[] { new Guid("34500000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000003"), null, null, true, 1, new DateTime(2026, 8, 31, 9, 15, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), 1, 2400m });
migrationBuilder.InsertData(
table: "ShopPOItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_ShopPOId", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
values: new object[] { new Guid("34000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000003"), true, 20, 20, 120m });
migrationBuilder.InsertData(
table: "Payments",
columns: new[] { "Id", "Amount", "ChequeDate", "ChequeNumber", "CollectedAt", "CollectedBy", "CreatedAt", "EntryMode", "FK_InvoiceId", "IsActive", "Method", "RecordedBy" },
values: new object[,]
{
{ new Guid("47000000-0000-0000-0000-000000000002"), 1000m, null, null, new DateTime(2026, 8, 31, 9, 20, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 31, 9, 20, 0, 0, DateTimeKind.Unspecified), 0, new Guid("34500000-0000-0000-0000-000000000002"), true, 0, null },
{ new Guid("47000000-0000-0000-0000-000000000003"), 800m, new DateTime(2026, 8, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), "000456", new DateTime(2026, 8, 31, 11, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 31, 18, 0, 0, 0, DateTimeKind.Unspecified), 1, new Guid("34500000-0000-0000-0000-000000000002"), true, 1, new Guid("29000000-0000-0000-0000-000000000001") },
{ new Guid("47000000-0000-0000-0000-000000000004"), 300m, null, null, new DateTime(2026, 8, 31, 11, 5, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 31, 11, 5, 0, 0, DateTimeKind.Unspecified), 0, new Guid("34500000-0000-0000-0000-000000000002"), true, 2, null }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "ShopPOItems",
keyColumn: "Id",
keyValue: new Guid("34000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "Invoices",
keyColumn: "Id",
keyValue: new Guid("34500000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "ShopPOs",
keyColumn: "Id",
keyValue: new Guid("33000000-0000-0000-0000-000000000003"));
}
}
}

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