Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf8d1c702e | ||
|
|
06a0e6ff13 | ||
|
|
d2b5733bd6 | ||
|
|
07883f7e51 | ||
|
|
224fa9c3cb | ||
|
|
f1c4f18931 | ||
|
|
da40524609 | ||
|
|
974b4c0801 | ||
|
|
adf00cf3d0 | ||
|
|
6f9da85f8e | ||
|
|
ff8d192bf0 | ||
|
|
744f7133c2 | ||
|
|
f65aaaf6b1 | ||
|
|
7dbd319726 | ||
|
|
f9c821f14f | ||
|
|
256373b913 | ||
|
|
a2f7b15d12 | ||
|
|
d30a0192ed | ||
|
|
96b523aa1e | ||
|
|
850ef0f1ba | ||
|
|
080c6d61b0 | ||
|
|
98652b67b4 | ||
|
|
373074ccb2 | ||
|
|
283190db09 | ||
|
|
bcdee141c1 | ||
|
|
d552798934 | ||
|
|
84f84e8d63 | ||
|
|
40dac3a838 | ||
|
|
78bd98e72f | ||
|
|
9b4e2c4811 | ||
|
|
c76dc2a187 | ||
|
|
66ba61f986 | ||
|
|
d4cf2f23d4 | ||
|
|
f67168f55a | ||
|
|
2d67c451e5 | ||
|
|
baab144c86 | ||
|
|
3a32b00766 | ||
|
|
4dcc0519a2 | ||
|
|
56d651f01c | ||
|
|
cfe1d49da9 | ||
|
|
bcd4be20ad | ||
|
|
3f4bfb0985 | ||
|
|
31878d6ffc |
@@ -1,84 +0,0 @@
|
||||
name: Backend - Build & Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "backend/**/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "backend/**/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.4"
|
||||
cache-dependency-path: backend/gestion/go.sum
|
||||
|
||||
- name: Download dependencies
|
||||
working-directory: backend/gestion
|
||||
run: go mod download
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
continue-on-error: true
|
||||
with:
|
||||
version: latest
|
||||
working-directory: backend/gestion
|
||||
args: --timeout=5m
|
||||
|
||||
- name: Install & run gosec
|
||||
working-directory: backend/gestion
|
||||
continue-on-error: true
|
||||
run: |
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||
gosec ./...
|
||||
|
||||
- name: Build
|
||||
working-directory: backend/gestion
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-binary
|
||||
path: backend/gestion/gestion
|
||||
retention-days: 7
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build & push backend (runtime)
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker-prod/backend/Dockerfile
|
||||
target: runtime
|
||||
push: true
|
||||
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'latest' || 'pre-prod' }}
|
||||
|
||||
- name: Build & push WAF
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker-prod/backend/Dockerfile
|
||||
target: waf
|
||||
push: true
|
||||
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'waf' || 'waf-pre-prod' }}
|
||||
@@ -1,160 +0,0 @@
|
||||
name: Frontend Admin - EAS Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-admin/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-admin/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend-admin/package-lock.json
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install EAS CLI & tooling
|
||||
run: |
|
||||
n=0
|
||||
until [ $n -ge 3 ]; do
|
||||
npm install -g eas-cli && break
|
||||
n=$((n + 1))
|
||||
echo "npm install -g eas-cli failed (attempt $n/3), cleaning up and retrying in 5s..."
|
||||
npm uninstall -g eas-cli >/dev/null 2>&1 || true
|
||||
rm -rf "$(npm root -g)/eas-cli" "$(npm root -g)"/.eas-cli-* 2>/dev/null || true
|
||||
sleep 5
|
||||
done
|
||||
command -v eas >/dev/null || { echo "::error::eas-cli installation failed after 3 attempts"; exit 1; }
|
||||
pip install -r scripts/requirements.txt awscli --quiet
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend-admin
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: frontend-admin
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Determine config
|
||||
id: config
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.base_ref }}" = "main" ]; then
|
||||
echo "profile=production" >> $GITHUB_OUTPUT
|
||||
echo "channel=production-admin" >> $GITHUB_OUTPUT
|
||||
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=https://mln-uber.club" >> $GITHUB_OUTPUT
|
||||
echo "xavia_url=https://ota-prod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PROD }}" >> $GITHUB_OUTPUT
|
||||
echo "apk_name=admin-panel-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "profile=pre-prod" >> $GITHUB_OUTPUT
|
||||
echo "channel=pre-prod-admin" >> $GITHUB_OUTPUT
|
||||
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=https://5.181.0.112.nip.io" >> $GITHUB_OUTPUT
|
||||
echo "xavia_url=https://ota-preprod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PREPROD }}" >> $GITHUB_OUTPUT
|
||||
echo "apk_name=admin-panel-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Inject EAS project ID
|
||||
working-directory: frontend-admin
|
||||
run: |
|
||||
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
|
||||
mv app.tmp.json app.json
|
||||
|
||||
- name: Select code signing certificate
|
||||
# certs/certificate.pem (committé) correspond à la clé de signature
|
||||
# du serveur OTA de production ; le serveur pre-prod signe avec une
|
||||
# clé différente (PRIVATE_KEY_PREPROD côté ota-uber), donc les builds
|
||||
# pre-prod doivent embarquer certs/certificate-preprod.pem à la place,
|
||||
# sous peine de voir toute MAJ OTA rejetée silencieusement (signature
|
||||
# invalide) sur ce canal.
|
||||
working-directory: frontend-admin
|
||||
run: |
|
||||
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
|
||||
cp certs/certificate-preprod.pem certs/certificate.pem
|
||||
fi
|
||||
|
||||
- name: Restore Gradle cache (RustFS)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
||||
S3_BUCKET: apk-builds
|
||||
run: python scripts/eas_cache.py restore --app frontend-admin
|
||||
|
||||
- name: Build APK (local)
|
||||
working-directory: frontend-admin
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
|
||||
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2"
|
||||
JAVA_TOOL_OPTIONS: "-Xmx3g"
|
||||
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
|
||||
|
||||
- name: Save Gradle cache (RustFS)
|
||||
if: success() || failure()
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
||||
S3_BUCKET: apk-builds
|
||||
run: python scripts/eas_cache.py save --app frontend-admin
|
||||
|
||||
- name: Rename & upload APK to RustFS
|
||||
working-directory: frontend-admin
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
mv *.apk ${{ steps.config.outputs.apk_name }}
|
||||
aws s3 cp ${{ steps.config.outputs.apk_name }} \
|
||||
s3://apk-builds/${{ steps.config.outputs.profile }}/${{ steps.config.outputs.apk_name }} \
|
||||
--endpoint-url https://rustfs.uber-stup.club \
|
||||
--no-verify-ssl
|
||||
|
||||
- name: Publish OTA update to Xavia
|
||||
if: github.event_name == 'push'
|
||||
working-directory: frontend-admin
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
|
||||
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
run: |
|
||||
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
|
||||
npx expo export --platform android --output-dir dist
|
||||
cd dist && zip -r ../bundle.zip . && cd ..
|
||||
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
|
||||
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
|
||||
-F "file=@bundle.zip" \
|
||||
-F "runtimeVersion=$RUNTIME_VERSION" \
|
||||
-F "channel=${{ steps.config.outputs.channel }}" \
|
||||
-F "commitHash=${{ github.sha }}" \
|
||||
-F "commitMessage=${{ steps.config.outputs.message }}" \
|
||||
--fail
|
||||
@@ -1,160 +0,0 @@
|
||||
name: Frontend Client - EAS Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "mobile/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "mobile/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: mobile/package-lock.json
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install EAS CLI & tooling
|
||||
run: |
|
||||
n=0
|
||||
until [ $n -ge 3 ]; do
|
||||
npm install -g eas-cli && break
|
||||
n=$((n + 1))
|
||||
echo "npm install -g eas-cli failed (attempt $n/3), cleaning up and retrying in 5s..."
|
||||
npm uninstall -g eas-cli >/dev/null 2>&1 || true
|
||||
rm -rf "$(npm root -g)/eas-cli" "$(npm root -g)"/.eas-cli-* 2>/dev/null || true
|
||||
sleep 5
|
||||
done
|
||||
command -v eas >/dev/null || { echo "::error::eas-cli installation failed after 3 attempts"; exit 1; }
|
||||
pip install -r scripts/requirements.txt awscli --quiet
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: mobile
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: mobile
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Determine config
|
||||
id: config
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.base_ref }}" = "main" ]; then
|
||||
echo "profile=production" >> $GITHUB_OUTPUT
|
||||
echo "channel=production-client" >> $GITHUB_OUTPUT
|
||||
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "xavia_url=https://ota-mobile-prod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||
echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PROD }}" >> $GITHUB_OUTPUT
|
||||
echo "apk_name=mobile-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "profile=pre-prod" >> $GITHUB_OUTPUT
|
||||
echo "channel=pre-prod-client" >> $GITHUB_OUTPUT
|
||||
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "xavia_url=https://ota-mobile-preprod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||
echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PREPROD }}" >> $GITHUB_OUTPUT
|
||||
echo "apk_name=mobile-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Inject EAS project ID
|
||||
working-directory: mobile
|
||||
run: |
|
||||
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
|
||||
mv app.tmp.json app.json
|
||||
|
||||
- name: Select code signing certificate
|
||||
# certs/certificate.pem (committé) correspond à la clé de signature
|
||||
# du serveur OTA mobile de production ; le serveur pre-prod signe
|
||||
# avec une clé différente (PRIVATE_KEY_MOBILE_PREPROD côté ota-uber),
|
||||
# donc les builds pre-prod doivent embarquer
|
||||
# certs/certificate-preprod.pem à la place, sous peine de voir toute
|
||||
# MAJ OTA rejetée silencieusement (signature invalide) sur ce canal.
|
||||
working-directory: mobile
|
||||
run: |
|
||||
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
|
||||
cp certs/certificate-preprod.pem certs/certificate.pem
|
||||
fi
|
||||
|
||||
- name: Restore Gradle cache (RustFS)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
||||
S3_BUCKET: apk-builds
|
||||
run: python scripts/eas_cache.py restore --app mobile
|
||||
|
||||
- name: Build APK (local)
|
||||
working-directory: mobile
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
|
||||
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2 -Dorg.gradle.internal.repository.max.retries=10 -Dorg.gradle.internal.repository.initial.backoff.ms=1000"
|
||||
JAVA_TOOL_OPTIONS: "-Xmx3g"
|
||||
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
|
||||
|
||||
- name: Save Gradle cache (RustFS)
|
||||
if: success() || failure()
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
||||
S3_BUCKET: apk-builds
|
||||
run: python scripts/eas_cache.py save --app mobile
|
||||
|
||||
- name: Rename & upload APK to RustFS
|
||||
working-directory: mobile
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
mv *.apk ${{ steps.config.outputs.apk_name }}
|
||||
aws s3 cp ${{ steps.config.outputs.apk_name }} \
|
||||
s3://apk-builds/${{ steps.config.outputs.profile }}/${{ steps.config.outputs.apk_name }} \
|
||||
--endpoint-url https://rustfs.uber-stup.club \
|
||||
--no-verify-ssl
|
||||
|
||||
- name: Publish OTA update to Xavia
|
||||
if: github.event_name == 'push'
|
||||
working-directory: mobile
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
|
||||
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
run: |
|
||||
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
|
||||
npx expo export --platform android --output-dir dist
|
||||
cd dist && zip -r ../bundle.zip . && cd ..
|
||||
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
|
||||
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
|
||||
-F "file=@bundle.zip" \
|
||||
-F "runtimeVersion=$RUNTIME_VERSION" \
|
||||
-F "channel=${{ steps.config.outputs.channel }}" \
|
||||
-F "commitHash=${{ github.sha }}" \
|
||||
-F "commitMessage=${{ steps.config.outputs.message }}" \
|
||||
--fail
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Frontend Web - Build & Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-prep/**"
|
||||
- "docker-pre-prod/frontend/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-prep/**"
|
||||
- "docker-pre-prod/frontend/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend-prep/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend-prep
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck & lint
|
||||
working-directory: frontend-prep
|
||||
run: |
|
||||
npx tsc -b --noEmit
|
||||
npm run lint
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend-prep
|
||||
env:
|
||||
VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }}
|
||||
run: npm run build
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build & push frontend
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker-prod/frontend/Dockerfile
|
||||
push: true
|
||||
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
|
||||
build-args: |
|
||||
VITE_TOMTOM_API_KEY=${{ secrets.VITE_TOMTOM_API_KEY }}
|
||||
+4
-6
@@ -1,10 +1,8 @@
|
||||
# Expo local state (in sub-projects)
|
||||
**/.expo/
|
||||
test_address.sh
|
||||
easpip
|
||||
ansible/
|
||||
dist/
|
||||
frontend-prep2/
|
||||
scripts/data.txt
|
||||
scripts/data2.txt
|
||||
scripts/data3.txt
|
||||
docker-prod/
|
||||
.ssh
|
||||
mc_utilisation
|
||||
monitoring/*/certs
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
# Infrastructure — Projet Gestion Commande
|
||||
|
||||
## Schéma global
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
%% ─── Clients ───────────────────────────────────────────────
|
||||
subgraph Clients["Clients"]
|
||||
WEB["🌐 Web\nReact / Vite\n(frontend-prep)"]
|
||||
MOB["📱 Mobile client\nReact Native / Expo\n(mobile/)"]
|
||||
ADM["📱 Mobile admin\nReact Native / Expo\n(frontend-admin/)"]
|
||||
end
|
||||
|
||||
%% ─── Services externes ──────────────────────────────────────
|
||||
subgraph External["Services externes"]
|
||||
TOMTOM["TomTom API\nMaps & routing"]
|
||||
TELEGRAM["Telegram\nBot / Webhooks"]
|
||||
NOWPAY["NowPayments\nCrypto IPN"]
|
||||
DOCKERHUB["Docker Hub\nxor1234/backend-mln\nxor1234/frontend-mln"]
|
||||
EAS["Expo EAS\nAPK builds"]
|
||||
end
|
||||
|
||||
%% ─── CI/CD ──────────────────────────────────────────────────
|
||||
subgraph CICD["CI/CD — GitHub Actions"]
|
||||
GHA_B_PP["backend-build.yml (pre-prod)\nlint → build → docker → SSH deploy"]
|
||||
GHA_F_PP["frontend-web-build.yml (pre-prod)\nlint → build → docker → SSH deploy"]
|
||||
GHA_B["backend-build.yml (main)\nlint → build → docker → SSH deploy"]
|
||||
GHA_F["frontend-web-build.yml (main)\nlint → build → docker → SSH deploy"]
|
||||
GHA_A["frontend-admin-build.yml\ntypecheck → EAS APK"]
|
||||
GHA_C["frontend-client-build.yml\ntypecheck → EAS APK"]
|
||||
end
|
||||
|
||||
%% ─── VPS Pre-prod ───────────────────────────────────────────
|
||||
subgraph PreProdVPS["VPS Pre-prod"]
|
||||
subgraph GestionNetPP["Docker network : gestion-network"]
|
||||
WAF_PP["🛡️ WAF\nnginx + ModSecurity CRS\n:80 / :443 ← public"]
|
||||
BACK_PP["⚙️ Backend\nGo 1.24 + Gin\n:8080 ← interne"]
|
||||
FRONT_PP["🖥️ Frontend\nReact SPA — nginx\n:80 ← interne"]
|
||||
PG_PP["🗄️ PostgreSQL 16\n← interne"]
|
||||
REDIS_PP["⚡ Redis 7\n← interne"]
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── VPS Production ─────────────────────────────────────────
|
||||
subgraph ProdVPS["VPS Production — mln-uber.club"]
|
||||
subgraph GestionNet["Docker network : gestion-network"]
|
||||
WAF["🛡️ WAF\nnginx + ModSecurity CRS\nParanoia L2\n:80 / :443 ← public"]
|
||||
BACK["⚙️ Backend\nGo 1.24 + Gin\n:8080 ← interne"]
|
||||
FRONT["🖥️ Frontend\nReact SPA — nginx\n:80 ← interne"]
|
||||
PG["🗄️ PostgreSQL 16\ngestion_db\n← interne"]
|
||||
REDIS["⚡ Redis 7\nSessions · Queue · Cache\n256 MB LRU ← interne"]
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── VPS Monitoring ─────────────────────────────────────────
|
||||
subgraph MonVPS["VPS Monitoring — uber-stup.club"]
|
||||
subgraph MonNet["Docker network : monitoring_net"]
|
||||
MNGINX["🔀 Nginx RP\n:80 / :443 ← public\ndozzle / wazuh\nrustfs / s3 / ota"]
|
||||
DOZZLE["📋 Dozzle\nLogs temps réel\n:8080 ← interne"]
|
||||
WAZUH_M["🔍 Wazuh Manager\n:1514 agents\n:1515 enroll\n:514 syslog"]
|
||||
WAZUH_I["🗂️ Wazuh Indexer\nOpenSearch :9200"]
|
||||
WAZUH_D["📊 Wazuh Dashboard\nKibana :5601"]
|
||||
RUSTFS["🗃️ RustFS\nS3-compatible :9000\nConsole :9001"]
|
||||
XAVIA["🚀 Xavia OTA\nNext.js :3000"]
|
||||
XAVIA_DB["🗄️ PostgreSQL 16\nxavia_db ← interne"]
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── Flux clients ───────────────────────────────────────────
|
||||
WEB -->|HTTPS| WAF
|
||||
MOB -->|HTTPS| WAF
|
||||
ADM -->|HTTPS| WAF
|
||||
|
||||
%% ─── Routage WAF ────────────────────────────────────────────
|
||||
WAF -->|"/api/*"| BACK
|
||||
WAF -->|"/* SPA"| FRONT
|
||||
WAF -->|"/uploads/*"| BACK
|
||||
|
||||
%% ─── Backend ↔ données ──────────────────────────────────────
|
||||
BACK --> PG
|
||||
BACK --> REDIS
|
||||
|
||||
%% ─── Backend ↔ services externes ────────────────────────────
|
||||
BACK -->|"Geocoding / ETA"| TOMTOM
|
||||
BACK -->|"Webhook"| TELEGRAM
|
||||
BACK -->|"IPN callback"| NOWPAY
|
||||
|
||||
%% ─── CI/CD ──────────────────────────────────────────────────
|
||||
GHA_B_PP -->|"push :latest + :waf"| DOCKERHUB
|
||||
GHA_F_PP -->|"push :latest"| DOCKERHUB
|
||||
GHA_B_PP -->|"SSH deploy (SERVER_HOST)"| PreProdVPS
|
||||
GHA_F_PP -->|"SSH deploy (SERVER_HOST)"| PreProdVPS
|
||||
GHA_B -->|"push :latest + :waf"| DOCKERHUB
|
||||
GHA_F -->|"push :latest"| DOCKERHUB
|
||||
GHA_B -->|"SSH deploy (SERVER_HOST_PROD)"| ProdVPS
|
||||
GHA_F -->|"SSH deploy (SERVER_HOST_PROD)"| ProdVPS
|
||||
GHA_A -->|"eas build android"| EAS
|
||||
GHA_C -->|"eas build android"| EAS
|
||||
DOCKERHUB -->|"docker pull"| WAF_PP
|
||||
DOCKERHUB -->|"docker pull"| BACK_PP
|
||||
DOCKERHUB -->|"docker pull"| FRONT_PP
|
||||
DOCKERHUB -->|"docker pull"| WAF
|
||||
DOCKERHUB -->|"docker pull"| BACK
|
||||
DOCKERHUB -->|"docker pull"| FRONT
|
||||
|
||||
%% ─── CI/CD mobile → OTA ────────────────────────────────────
|
||||
GHA_A -->|"eas build pre-prod"| XAVIA
|
||||
GHA_C -->|"eas build pre-prod"| XAVIA
|
||||
|
||||
%% ─── Monitoring ─────────────────────────────────────────────
|
||||
MNGINX --> DOZZLE
|
||||
MNGINX --> WAZUH_D
|
||||
MNGINX -->|"rustfs.uber-stup.club"| RUSTFS
|
||||
MNGINX -->|"s3.uber-stup.club"| RUSTFS
|
||||
MNGINX -->|"ota.uber-stup.club"| XAVIA
|
||||
WAZUH_D --> WAZUH_I
|
||||
WAZUH_M --> WAZUH_I
|
||||
XAVIA --> XAVIA_DB
|
||||
DOZZLE -->|"remote agent :7007"| ProdVPS
|
||||
DOZZLE -->|"remote agent :7007"| PreProdVPS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VPS Production
|
||||
|
||||
**Domaine** : `mln-uber.club`
|
||||
|
||||
### Services
|
||||
|
||||
| Conteneur | Image | Ports | Rôle |
|
||||
|---|---|---|---|
|
||||
| `gestion-waf` | `xor1234/backend-mln:waf` | **80, 443** (public) | Reverse proxy + WAF ModSecurity |
|
||||
| `gestion-backend` | `xor1234/backend-mln:latest` | 8080 (interne) | API Go/Gin |
|
||||
| `gestion-frontend` | `xor1234/frontend-mln:latest` | 80 (interne) | SPA React/Vite |
|
||||
| `gestion-postgres` | `postgres:16-alpine` | 5432 (interne) | Base de données principale |
|
||||
| `gestion-redis` | `redis:7-alpine` | 6379 (interne) | Cache · Sessions · File livreurs |
|
||||
|
||||
### Flux de trafic
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
INET["🌐 Internet"] -->|":443 TLS 1.2/1.3"| WAF
|
||||
|
||||
subgraph WAF["🛡️ WAF — nginx + ModSecurity CRS L2"]
|
||||
W1["Rate limit : 20 req/s/IP"]
|
||||
W2["HSTS 2 ans · CSP · X-Frame"]
|
||||
W3["Ban auto : SQLi / XSS / LFI / RCE"]
|
||||
end
|
||||
|
||||
WAF -->|"/api/*"| BACK["⚙️ Backend\nGo / Gin :8080"]
|
||||
WAF -->|"/* SPA"| FRONT["🖥️ Frontend\nnginx :80"]
|
||||
WAF -->|"/uploads/*\n(fichiers statiques)"| BACK
|
||||
|
||||
BACK --> PG["🗄️ PostgreSQL 16"]
|
||||
BACK --> REDIS["⚡ Redis 7\nSessions · Queue · Cache"]
|
||||
```
|
||||
|
||||
### Volumes persistants
|
||||
|
||||
| Volume | Usage |
|
||||
|---|---|
|
||||
| `postgres_data` | Données PostgreSQL |
|
||||
| `redis_data` | Persistance Redis (AOF) |
|
||||
| `backend_uploads` | Fichiers uploadés — monté en `:ro` dans le WAF pour `/uploads/` |
|
||||
|
||||
### Variables d'environnement requises
|
||||
|
||||
| Variable | Valeur par défaut | Description |
|
||||
|---|---|---|
|
||||
| `DB_PASSWORD` | — | Mot de passe PostgreSQL |
|
||||
| `DB_NAME` | `gestion_db` | Nom de la base |
|
||||
| `SESSION_SECRET` | — | Secret session Gin |
|
||||
| `USER_JWT_SECRET` | — | JWT clients |
|
||||
| `USER_JWT_SECRET_OLD` | — | JWT clients (rotation) |
|
||||
| `ADMIN_JWT_SECRET` | — | JWT admin/cabine |
|
||||
| `ADMIN_JWT_SECRET_OLD` | — | JWT admin (rotation) |
|
||||
| `REDIS_PASSWORD` | — | Mot de passe Redis |
|
||||
| `TOMTOM_API_KEY` | — | Clé TomTom Maps |
|
||||
| `TELEGRAM_WEBHOOK_URL` | — | URL webhook Telegram |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | — | Secret webhook Telegram |
|
||||
| `NOWPAYMENTS_IPN_SECRET` | — | Secret IPN NowPayments |
|
||||
|
||||
---
|
||||
|
||||
## VPS Pre-prod
|
||||
|
||||
Même stack que la production, déployé depuis la branche `pre-prod` via `SERVER_HOST` / `SERVER_SSH_KEY`.
|
||||
|
||||
### Services
|
||||
|
||||
| Conteneur | Image | Ports | Rôle |
|
||||
|---|---|---|---|
|
||||
| `gestion-waf` | `xor1234/backend-mln:waf` | **80, 443** (public) | WAF ModSecurity |
|
||||
| `gestion-backend` | `xor1234/backend-mln:latest` | 8080 (interne) | API Go/Gin |
|
||||
| `gestion-frontend` | `xor1234/frontend-mln:latest` | 80 (interne) | SPA React/Vite |
|
||||
| `gestion-postgres` | `postgres:16-alpine` | 5432 (interne) | Base de données |
|
||||
| `gestion-redis` | `redis:7-alpine` | 6379 (interne) | Cache · Sessions |
|
||||
|
||||
---
|
||||
|
||||
## VPS Monitoring
|
||||
|
||||
**Domaine** : `uber-stup.club`
|
||||
|
||||
### Services
|
||||
|
||||
| Conteneur | Image | Ports | Rôle |
|
||||
|---|---|---|---|
|
||||
| `monitoring_nginx` | `nginx:alpine` | **80, 443** (public) | Reverse proxy monitoring |
|
||||
| `dozzle` | `amir20/dozzle:latest` | 8080 (interne) | Logs Docker temps réel |
|
||||
| `wazuh.manager` | `wazuh/wazuh-manager:4.14.5` | 1514, 1515, 514/udp | SIEM — collecte agents |
|
||||
| `wazuh.indexer` | `wazuh/wazuh-indexer:4.14.5` | 9200 (interne) | OpenSearch (stockage events) |
|
||||
| `wazuh.dashboard` | `wazuh/wazuh-dashboard:4.14.5` | 5601 (interne) | Kibana (visualisation) |
|
||||
| `rustfs` | `rustfs/rustfs:latest` | 9000 S3, 9001 console (internes) | Stockage objet S3-compatible (APKs) |
|
||||
| `xavia` | `xaviaio/xavia-ota:latest` | 3000 (interne) | Serveur OTA Expo (Next.js) |
|
||||
| `xavia_db` | `postgres:16-alpine` | 5432 (interne) | Base de données Xavia |
|
||||
|
||||
### Accès publics
|
||||
|
||||
| URL | Service |
|
||||
|---|---|
|
||||
| `https://dozzle.uber-stup.club` | Interface logs Docker |
|
||||
| `https://wazuh.uber-stup.club` | Dashboard SIEM Wazuh |
|
||||
| `https://rustfs.uber-stup.club` | Console RustFS (stockage APKs) |
|
||||
| `https://s3.uber-stup.club` | API S3 RustFS |
|
||||
| `https://ota.uber-stup.club` | Dashboard & API Xavia OTA |
|
||||
|
||||
### Xavia OTA — configuration app Expo
|
||||
|
||||
```json
|
||||
"updates": {
|
||||
"url": "https://ota.uber-stup.club/api/manifest",
|
||||
"codeSigningCertificate": "./certs/certificate.pem",
|
||||
"codeSigningMetadata": {
|
||||
"keyid": "main",
|
||||
"algorithm": "rsa-v1_5-sha256"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clé privée RSA 4096 stockée sur le serveur dans `/home/ubuntu/xavia-keys/private-key.pem`.
|
||||
Le `certificate.pem` doit être commité dans le repo mobile sous `mobile/certs/certificate.pem`.
|
||||
|
||||
### Dozzle — agents distants
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
DOZZLE["📋 Dozzle\nuber-stup.club"] -->|":7007"| A1["VPS Production\n5.181.0.112"]
|
||||
DOZZLE -->|":7007"| A2["VPS Pre-prod\n185.234.9.102"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD
|
||||
|
||||
### Pipeline backend (pre-prod & main)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PUSH["push backend/**"] --> LINT["lint\ngolangci-lint"]
|
||||
LINT --> BUILD["build\ngo build ./..."]
|
||||
BUILD --> ARTIFACT["artifact\nbackend-binary 7j"]
|
||||
BUILD --> DOCKER{"push only?"}
|
||||
DOCKER -->|oui| D1["docker build runtime\n→ xor1234/backend-mln:latest"]
|
||||
DOCKER -->|oui| D2["docker build waf\n→ xor1234/backend-mln:waf"]
|
||||
D1 --> DEPLOY["SSH deploy\ndocker compose pull backend waf\ndocker compose up -d --no-deps backend waf"]
|
||||
D2 --> DEPLOY
|
||||
|
||||
style DOCKER fill:#f0f0f0
|
||||
```
|
||||
|
||||
### Pipeline frontend web (pre-prod & main)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PUSH["push frontend-prep/**\nou docker/frontend/**"] --> LINT["lint-typecheck\ntsc + eslint"]
|
||||
LINT --> BUILD["build\nnpm run build"]
|
||||
BUILD --> ARTIFACT["artifact\nfrontend-web-dist 7j"]
|
||||
BUILD --> DOCKER{"push only?"}
|
||||
DOCKER -->|oui| D1["docker build\n→ xor1234/frontend-mln:latest"]
|
||||
D1 --> DEPLOY["SSH deploy\ndocker compose pull frontend\ndocker compose up -d --no-deps frontend"]
|
||||
|
||||
style DOCKER fill:#f0f0f0
|
||||
```
|
||||
|
||||
### Pipeline mobile (main uniquement)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PUSH_A["push frontend-admin/**"] --> TC_A["typecheck\ntsc --noEmit"]
|
||||
TC_A --> EAS_A["EAS build android\n--profile production"]
|
||||
EAS_A --> APK_A["artifact\nadmin-panel-android-prod-apk 14j"]
|
||||
|
||||
PUSH_C["push mobile/**"] --> TC_C["typecheck\ntsc --noEmit"]
|
||||
TC_C --> EAS_C["EAS build android\n--profile production"]
|
||||
EAS_C --> APK_C["artifact\nclient-android-prod-apk 14j"]
|
||||
```
|
||||
|
||||
### Secrets GitHub requis
|
||||
|
||||
| Secret | Branche | Usage |
|
||||
|---|---|---|
|
||||
| `DOCKERHUB_USERNAME` | main + pre-prod | Login Docker Hub |
|
||||
| `DOCKERHUB_TOKEN` | main + pre-prod | Token Docker Hub |
|
||||
| `SERVER_HOST` | pre-prod | IP/hostname VPS pre-prod |
|
||||
| `SERVER_HOST_PROD` | main | IP/hostname VPS production |
|
||||
| `SERVER_USER` | main + pre-prod | Utilisateur SSH |
|
||||
| `SERVER_SSH_KEY` | pre-prod | Clé privée SSH ED25519 pre-prod |
|
||||
| `SERVER_SSH_KEY_PROD` | main | Clé privée SSH ED25519 prod |
|
||||
| `COMPOSE_PATH` | main + pre-prod | Chemin absolu docker-compose-prod.yml |
|
||||
| `EXPO_TOKEN` | main | Token Expo EAS |
|
||||
| `EXPO_PROJECT_ID` | main | ID projet EAS admin |
|
||||
| `EXPO_PROJECT_ID_CLIENT` | main | ID projet EAS client |
|
||||
| `VITE_TOMTOM_API_KEY` | main + pre-prod | Clé TomTom pour build frontend |
|
||||
|
||||
---
|
||||
|
||||
## Backend — architecture interne
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
MAIN["main.go\ninit DB · Redis · services · Gin"] --> ROUTES["routes/routes.go\npublic · client · admin · cabine · livreur"]
|
||||
ROUTES --> HANDLERS["handlers/\nauth · commands · delivery\npanier · notifications · payments"]
|
||||
HANDLERS --> MODELS["models/\nstructs GORM"]
|
||||
HANDLERS --> DB["db/\nconnexion · migrations · queries"]
|
||||
HANDLERS --> SERVICES["services/\nTomTom · Telegram · NowPayments"]
|
||||
MAIN --> WORKERS["workers/\ncron_auto_assign 5min\npayment_checker 2min\nqueue_cleanup 5min"]
|
||||
MAIN --> MW["middleware/\nsession · block · clock"]
|
||||
```
|
||||
|
||||
### Rôles utilisateurs
|
||||
|
||||
| Rôle | Accès |
|
||||
|---|---|
|
||||
| `client` | Panier, commandes, profil, suivi |
|
||||
| `admin` | Gestion complète (commandes, produits, livreurs, stats) |
|
||||
| `cabine` | Mise à jour statut commandes + notification client |
|
||||
| `livreur` | Tableau de bord livraisons, GPS, statut |
|
||||
@@ -240,7 +240,7 @@ graph TD
|
||||
P3["GET /api/v1/products/category/:category"]
|
||||
P4["GET /api/v1/categories"]
|
||||
P5["GET /api/v1/app-settings"]
|
||||
P6["POST /api/v1/webhook/nowpayments"]
|
||||
P6["POST /api/v1/webhooks/nowpayments"]
|
||||
P7["POST /webhook/telegram"]
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
img.jpg
|
||||
video.mp4
|
||||
s.sh
|
||||
.env
|
||||
uploads/
|
||||
openapi.yaml
|
||||
@@ -1,31 +0,0 @@
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- gosec
|
||||
- gocritic
|
||||
- misspell
|
||||
- bodyclose
|
||||
- noctx
|
||||
|
||||
linters-settings:
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # erreurs non vérifiées (couvertes par errcheck)
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
- sloppyReassign
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gosec
|
||||
- errcheck
|
||||
max-issues-per-linter: 50
|
||||
max-same-issues: 5
|
||||
@@ -1,63 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (d *Database) CheckAddress(addressByUser *models.Command) error {
|
||||
var correction models.Address
|
||||
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
|
||||
if result.Error == nil {
|
||||
addressByUser.DeliveryAddress = correction.CorrectAddress
|
||||
return fmt.Errorf("adresse invalide %s", correction.CorrectAddress)
|
||||
}
|
||||
if !isNotFound(result.Error) {
|
||||
return fmt.Errorf("checkAddress: %w", result.Error)
|
||||
}
|
||||
|
||||
// Pas de correspondance exacte — fallback sur une comparaison normalisée
|
||||
// (accents/casse/espaces) pour rattraper les variantes mineures de saisie.
|
||||
corrections, err := d.AllAddress()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
normalizedInput := utils.NormalizeAddress(addressByUser.DeliveryAddress)
|
||||
for _, c := range corrections {
|
||||
if strings.EqualFold(utils.NormalizeAddress(c.InvalidAddress), normalizedInput) {
|
||||
addressByUser.DeliveryAddress = c.CorrectAddress
|
||||
return fmt.Errorf("adresse invalide %s", c.CorrectAddress)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error {
|
||||
address := models.Address{
|
||||
InvalidAddress: InvalidAddressByAdmin,
|
||||
CorrectAddress: CorrectAddressByAdmin,
|
||||
}
|
||||
if err := d.GDB.Create(&address).Error; err != nil {
|
||||
return fmt.Errorf("addAddress: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteAddress(InvalidAddressByAdmin string, CorrectAddressByAdmin string) error {
|
||||
result := d.GDB.Where("invalid_address = ? AND correct_address = ?", InvalidAddressByAdmin, CorrectAddressByAdmin).
|
||||
Delete(&models.Address{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("deleteAddress: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AllAddress() ([]models.Address, error) {
|
||||
var addresses []models.Address
|
||||
if err := d.GDB.Find(&addresses).Error; err != nil {
|
||||
return nil, fmt.Errorf("getAllAddress: %w", err)
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) CreateAlert(username string, message string) (models.AlertPolicy, error) {
|
||||
alert := models.AlertPolicy{
|
||||
Username: username,
|
||||
Status: "true",
|
||||
Message: message,
|
||||
}
|
||||
if err := d.GDB.Create(&alert).Error; err != nil {
|
||||
return models.AlertPolicy{}, err
|
||||
}
|
||||
return alert, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
|
||||
var alert models.AlertPolicy
|
||||
if err := d.GDB.First(&alert, id).Error; err != nil {
|
||||
return models.AlertPolicy{}, err
|
||||
}
|
||||
return alert, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
|
||||
var alerts []models.AlertPolicy
|
||||
if err := d.GDB.Order("created_at DESC").Limit(500).Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteAlertPolicy(id int) error {
|
||||
return d.GDB.Delete(&models.AlertPolicy{}, id).Error
|
||||
}
|
||||
|
||||
func (d *Database) EndAlert(id int) error {
|
||||
result := d.GDB.Model(&models.AlertPolicy{}).
|
||||
Where("id = ? AND status = 'true'", id).
|
||||
Update("status", "false")
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("alerte non trouvée ou déjà terminée")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ActivateAlert(id int) error {
|
||||
result := d.GDB.Model(&models.AlertPolicy{}).
|
||||
Where("id = ? AND status = 'false'", id).
|
||||
Update("status", "true")
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("alerte non trouvée ou déjà active")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
|
||||
var alerts []models.AlertPolicy
|
||||
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Limit(100).Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
|
||||
var alerts []models.AlertPolicy
|
||||
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Limit(200).Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetActiveProductPrice retourne le prix catalogue actif pour un produit et
|
||||
// une quantité donnés (palier le plus proche ≤ quantity, cf. même requête que
|
||||
// AddToBasket) — utilisé pour calculer le prix effectif d'une récompense
|
||||
// "half_price_product" (50% de ce prix).
|
||||
func (d *Database) GetActiveProductPrice(productID int, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT price FROM product_prices
|
||||
WHERE product_id = ? AND quantity <= ? AND active_price = true
|
||||
ORDER BY quantity DESC LIMIT 1`,
|
||||
productID, quantity).Scan(&result).Error
|
||||
if err != nil || result.Price == 0 {
|
||||
return 0, fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
||||
}
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT price
|
||||
FROM product_prices pp
|
||||
INNER JOIN products p ON pp.product_id = p.id
|
||||
WHERE LOWER(p.name) = LOWER(?)
|
||||
AND LOWER(p.category) = LOWER(?)
|
||||
AND pp.quantity <= ?
|
||||
ORDER BY pp.quantity DESC
|
||||
LIMIT 1`, name, category, quantity).Scan(&result).Error
|
||||
if err != nil || result.Price == 0 {
|
||||
return 0, fmt.Errorf("prix produit introuvable pour %f %s: %w", quantity, name, err)
|
||||
}
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
// GetAllProductsInBasket récupère tous les produits du panier d'un utilisateur
|
||||
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
|
||||
var baskets []models.Panier
|
||||
err := d.GDB.Raw(`
|
||||
SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.is_reward, b.created_at,
|
||||
p.name as product_name, p.category, p.description
|
||||
FROM baskets b
|
||||
INNER JOIN products p ON b.product_id = p.id
|
||||
WHERE b.username = ?
|
||||
ORDER BY b.created_at DESC`, username).Scan(&baskets).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du panier: %w", err)
|
||||
}
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (is_reward = true),
|
||||
// au prix fourni par l'appelant dans chaque RewardItem.Price (0 pour un produit offert,
|
||||
// ou le prix effectif déjà calculé pour une remise — voir handlers/points.go).
|
||||
// Supprime les anciens items récompense avant d'insérer les nouveaux.
|
||||
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
|
||||
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||
var baskets []models.Panier
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
baskets, err = addRewardsToBasketTx(tx, username, items, poolKey)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// addRewardsToBasketTx contient la logique de remplacement des articles
|
||||
// récompense, factorisée pour être appelée soit seule (AddRewardsToBasket),
|
||||
// soit dans la même transaction qu'une autre opération (voir
|
||||
// ClaimPoolRewardAndAddToBasket) afin de garantir qu'une récompense n'est
|
||||
// jamais consommée sans que son produit soit effectivement livré.
|
||||
func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||
// Supprimer tout article récompense existant (remplacement)
|
||||
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
|
||||
var baskets []models.Panier
|
||||
for _, item := range items {
|
||||
if item.ProductID <= 0 || item.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
var productName string
|
||||
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
|
||||
return nil, fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
|
||||
}
|
||||
var basket models.Panier
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
||||
VALUES (?, ?, ?, ?, true, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
|
||||
username, item.ProductID, item.Quantity, item.Price, poolKey).Scan(&basket).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baskets = append(baskets, basket)
|
||||
}
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense.
|
||||
func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
|
||||
var counts struct {
|
||||
Total int `gorm:"column:total"`
|
||||
Normal int `gorm:"column:normal"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE is_reward = false) as normal
|
||||
FROM baskets WHERE username = ?`, username).Scan(&counts).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return counts.Total > 0 && counts.Normal == 0, nil
|
||||
}
|
||||
|
||||
// AddToBasket vérifie le stock disponible et ajoute l'article au panier.
|
||||
// Le stock n'est pas décrémenté ici — il l'est uniquement au checkout.
|
||||
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
|
||||
var basket models.Panier
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var currentStock float64
|
||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(¤tStock).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock: %w", err)
|
||||
}
|
||||
if currentStock < quantity {
|
||||
return fmt.Errorf("stock insuffisant")
|
||||
}
|
||||
|
||||
var priceResult struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT price FROM product_prices
|
||||
WHERE product_id = ? AND quantity <= ? AND active_price = true
|
||||
ORDER BY quantity DESC LIMIT 1`,
|
||||
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
|
||||
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
||||
}
|
||||
|
||||
var existing struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
// Chercher uniquement un item normal (non-récompense) pour ce produit
|
||||
tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
|
||||
username, productID).Scan(&existing)
|
||||
|
||||
if existing.ID != 0 {
|
||||
return tx.Raw(`
|
||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
||||
existing.Quantity+quantity, existing.Price+priceResult.Price,
|
||||
existing.ID).Scan(&basket).Error
|
||||
}
|
||||
return tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
||||
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
||||
username, productID, quantity, priceResult.Price).Scan(&basket).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &basket, nil
|
||||
}
|
||||
|
||||
// DeleteProductFromBasket supprime un produit spécifique du panier.
|
||||
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
|
||||
func (d *Database) DeleteProductFromBasket(basketID int) error {
|
||||
result := d.GDB.Exec(`DELETE FROM baskets WHERE id = ?`, basketID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearBasket vide complètement le panier d'un utilisateur.
|
||||
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
|
||||
func (d *Database) ClearBasket(username string) error {
|
||||
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
||||
var username string
|
||||
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if username == "" {
|
||||
return "", fmt.Errorf("article non trouvé")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
// GetUnavailableBasketItems retourne les noms des produits du panier dont tous les prix ont été désactivés.
|
||||
func (d *Database) GetUnavailableBasketItems(username string) ([]string, error) {
|
||||
var names []string
|
||||
err := d.GDB.Raw(`
|
||||
SELECT DISTINCT p.name
|
||||
FROM baskets b
|
||||
INNER JOIN products p ON b.product_id = p.id
|
||||
WHERE b.username = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM product_prices pp
|
||||
WHERE pp.product_id = b.product_id
|
||||
AND pp.quantity <= b.quantity
|
||||
AND pp.active_price = true
|
||||
)`, username).Scan(&names).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur vérification disponibilité: %w", err)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// GetReservedQuantityInBaskets retourne la somme des quantités d'un produit dans tous les paniers actifs.
|
||||
func (d *Database) GetReservedQuantityInBaskets(productID int) (float64, error) {
|
||||
var total float64
|
||||
err := d.GDB.Raw(`SELECT COALESCE(SUM(quantity), 0) FROM baskets WHERE product_id = ?`, productID).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lecture réservations panier: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItems(username string) ([]map[string]any, error) {
|
||||
var items []map[string]any
|
||||
if err := d.GDB.Raw(`SELECT product_id, quantity::float8 as quantity, price::float8 as price, is_reward FROM baskets WHERE username = ?`,
|
||||
username).Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) CancelCommandAtomic(commandID int, username, reason string, force bool) (int, error) {
|
||||
log.Printf("🔒 [CancelAtomic] START - cmd=%d, user=%s, force=%v", commandID, username, force)
|
||||
|
||||
var penalty int
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmdResult.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [CancelAtomic] Trouvée - status=%s, owner=%s, livreur=%s",
|
||||
cmdResult.Status, cmdResult.Username, cmdResult.LivreurAssign)
|
||||
|
||||
if cmdResult.Username != username {
|
||||
return fmt.Errorf("commande ne vous appartient pas")
|
||||
}
|
||||
|
||||
nonCancellableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
|
||||
if slices.Contains(nonCancellableStatuses, cmdResult.Status) {
|
||||
return fmt.Errorf("impossible d'annuler")
|
||||
}
|
||||
|
||||
isLateCancel := false
|
||||
if cmdResult.LivreurAssign != "" {
|
||||
if cmdResult.Status == "en_route" || cmdResult.Status == "arrived" {
|
||||
isLateCancel = true
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - Statut: %s", cmdResult.Status)
|
||||
} else if d.CheckCommandETAExistsAndValid(commandID) {
|
||||
isLateCancel = true
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - ETA définie")
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Statut: %s, Pas d'ETA valide", cmdResult.Status)
|
||||
}
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Aucun livreur assigné")
|
||||
}
|
||||
|
||||
if isLateCancel && !force {
|
||||
return fmt.Errorf("confirmation requise")
|
||||
}
|
||||
|
||||
cancelMsg := reason
|
||||
if cancelMsg == "Annulation par le client" {
|
||||
cancelMsg = ""
|
||||
}
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes
|
||||
SET status = 'cancelled', cancel_reason = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = ? AND username = ?`, cancelMsg, commandID, cmdResult.Status, username)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande déjà modifiée")
|
||||
}
|
||||
log.Printf("✅ [CancelAtomic] Statut mis à jour: %s → cancelled", cmdResult.Status)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT product_id, SUM(quantite) AS total_qty
|
||||
FROM command_items
|
||||
WHERE command_id = ?
|
||||
GROUP BY product_id
|
||||
) agg
|
||||
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
|
||||
return fmt.Errorf("erreur remboursement stock: %w", err)
|
||||
}
|
||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username).Error; err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur incrémentation count: %v", err)
|
||||
}
|
||||
|
||||
if isLateCancel {
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation tardive confirmée - Application pénalité")
|
||||
penalty, _ = d.CalculateCancellationPenalty(username)
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET amende = amende + ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, penalty, username).Error; err != nil {
|
||||
log.Printf("❌ [CancelAtomic] Erreur pénalité: %v", err)
|
||||
} else {
|
||||
log.Printf("⚠️ [CancelAtomic] Pénalité: %d appliquée à %s", penalty, username)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, 'cancelled', ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID,
|
||||
fmt.Sprintf("Annulée par %s - Raison: %s", username, reason),
|
||||
username,
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur log: %v", err)
|
||||
}
|
||||
|
||||
// Nettoyage async après commit
|
||||
livreur := cmdResult.LivreurAssign
|
||||
if livreur != "" {
|
||||
go func() {
|
||||
if err := d.CleanupCompletedCommandFromQueue(commandID, livreur); err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur cleanup queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
Redis.Del(RedisCtx,
|
||||
fmt.Sprintf("command:%d", commandID),
|
||||
fmt.Sprintf("client:%s", username),
|
||||
fmt.Sprintf("client:%s:commands", username),
|
||||
)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [CancelAtomic] SUCCÈS - Commande %d annulée", commandID)
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
etaData, err := Redis.HGetAll(RedisCtx, etaKey).Result()
|
||||
if err != nil || len(etaData) == 0 {
|
||||
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
|
||||
return false
|
||||
}
|
||||
|
||||
var etaMinutes int
|
||||
if _, err := fmt.Sscanf(etaData["eta_minutes"], "%d", &etaMinutes); err != nil || etaMinutes <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaData["eta_minutes"])
|
||||
return false
|
||||
}
|
||||
|
||||
ttl, err := Redis.TTL(RedisCtx, etaKey).Result()
|
||||
if err != nil || ttl <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA expirée pour cmd %d", commandID)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("✅ [CheckETA] ETA valide trouvée pour cmd %d: %d min (TTL: %v)", commandID, etaMinutes, ttl)
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmdResult.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
|
||||
|
||||
// ✅ Ne restitue le stock QUE si pas déjà fait
|
||||
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved" || cmdResult.Status == "livre"
|
||||
if !stockAlreadyRestored {
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT product_id, SUM(quantite) AS total_qty
|
||||
FROM command_items
|
||||
WHERE command_id = ?
|
||||
GROUP BY product_id
|
||||
) agg
|
||||
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
|
||||
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⏭️ [DeleteAtomic] Stock NON restitué - statut=%s", cmdResult.Status)
|
||||
}
|
||||
|
||||
// ✅ Log suppression
|
||||
tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "deleted",
|
||||
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, cmdResult.Status),
|
||||
deletedBy)
|
||||
|
||||
if err := tx.Exec(`DELETE FROM command_items WHERE command_id = ?`, commandID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := tx.Exec(`DELETE FROM commandes WHERE id = ?`, commandID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteAtomic] Supprimée de la DB")
|
||||
|
||||
livreur := cmdResult.LivreurAssign
|
||||
cmdUsername := cmdResult.Username
|
||||
|
||||
if livreur != "" {
|
||||
go d.RemoveCommandFromAllQueues(commandID, livreur)
|
||||
}
|
||||
go func() {
|
||||
Redis.Del(RedisCtx,
|
||||
fmt.Sprintf("command:%d", commandID),
|
||||
fmt.Sprintf("client:%s", cmdUsername),
|
||||
fmt.Sprintf("client:%s:commands", cmdUsername),
|
||||
)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandPositionInQueue(livreurUsername string, commandID int) (int, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", livreurUsername)
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("commande non trouvée dans la queue")
|
||||
}
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCancelledCommands(username string, limit int) ([]map[string]any, error) {
|
||||
query := `
|
||||
SELECT id, client_order_id AS client_order_number, username, status, adresse, total_prix::float8 as total_prix, created_at, updated_at, COALESCE(cancel_reason, '') AS cancel_reason
|
||||
FROM commandes
|
||||
WHERE status = 'cancelled'`
|
||||
|
||||
args := []any{}
|
||||
|
||||
if username != "" {
|
||||
query += " AND username = ?"
|
||||
args = append(args, username)
|
||||
}
|
||||
|
||||
query += " ORDER BY updated_at DESC"
|
||||
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// AddClientPenalty ajoute une pénalité à un client
|
||||
func (d *Database) AddClientPenalty(username string, points int) error {
|
||||
log.Printf("⚠️ [AddPenalty] Ajout pénalité: %d points pour client %s", points, username)
|
||||
|
||||
if points <= 0 {
|
||||
return fmt.Errorf("points invalides: %d", points)
|
||||
}
|
||||
if username == "" {
|
||||
return fmt.Errorf("username vide")
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE clients
|
||||
SET amende = amende + ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, points, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [AddPenalty] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur ajout pénalité: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé: %s", username)
|
||||
}
|
||||
|
||||
log.Printf("✅ [AddPenalty] Pénalité ajoutée: +%d points pour %s", points, username)
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelCommandByAdminAtomic transitionne une commande vers 'cancelled' depuis le
|
||||
// panel admin/cabine de façon atomique (verrou FOR UPDATE sur la commande) : le
|
||||
// remboursement de stock et le changement de statut se font dans la même
|
||||
// transaction, conditionnés à une lecture du statut précédent faite sous verrou.
|
||||
// Corrige un double remboursement possible sur double-tap/appel concurrent —
|
||||
// l'ancien code (RestoreCommandStock + UpdateCommandStatus appelés séparément
|
||||
// par le handler) lisait le statut puis restaurait le stock hors transaction,
|
||||
// laissant une fenêtre où deux requêtes concurrentes lisaient toutes les deux
|
||||
// "pas encore annulée" et remboursaient chacune le stock.
|
||||
func (d *Database) CancelCommandByAdminAtomic(commandID int) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var prevStatus string
|
||||
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if prevStatus == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
|
||||
if !slices.Contains(noRestoreStatuses, prevStatus) {
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT product_id, SUM(quantite) AS total_qty
|
||||
FROM command_items
|
||||
WHERE command_id = ?
|
||||
GROUP BY product_id
|
||||
) agg
|
||||
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
|
||||
return fmt.Errorf("erreur remboursement stock: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, commandID).Error; err != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CancelDeliveryByLivreurAtomic annule une commande côté livreur et restaure le stock
|
||||
// de manière atomique (verrou FOR UPDATE + transition conditionnée à l'ancien statut).
|
||||
// Idempotent : si la commande est déjà annulée, ne touche pas au stock et renvoie
|
||||
// alreadyCancelled=true — évite un remboursement en double en cas de double appel
|
||||
// (double-tap, retry réseau, ou commande déjà annulée par un autre canal).
|
||||
func (d *Database) CancelDeliveryByLivreurAtomic(commandID int) (alreadyCancelled bool, prevStatus string, err error) {
|
||||
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if prevStatus == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
if prevStatus == "cancelled" {
|
||||
alreadyCancelled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = ?`, commandID, prevStatus)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande déjà modifiée par une autre requête")
|
||||
}
|
||||
|
||||
if e := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT product_id, SUM(quantite) AS total_qty
|
||||
FROM command_items
|
||||
WHERE command_id = ?
|
||||
GROUP BY product_id
|
||||
) agg
|
||||
WHERE agg.product_id = p.id`, commandID).Error; e != nil {
|
||||
return fmt.Errorf("erreur remboursement stock: %w", e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
var hexColorRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||
|
||||
type Category struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"column:name"`
|
||||
Color string `json:"color" gorm:"column:color"`
|
||||
IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"`
|
||||
Position int `json:"position" gorm:"column:position"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (Category) TableName() string { return "categories" }
|
||||
|
||||
func ValidateCategoryColor(color string) error {
|
||||
if color == "" {
|
||||
return nil
|
||||
}
|
||||
if !hexColorRegex.MatchString(color) {
|
||||
return fmt.Errorf("couleur invalide : format hexadécimal requis (ex: #7c3aed)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllCategories() ([]Category, error) {
|
||||
var categories []Category
|
||||
if err := d.GDB.Order("position ASC, name ASC").Find(&categories).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if categories == nil {
|
||||
categories = []Category{}
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Category, error) {
|
||||
if color == "" {
|
||||
color = "#7c3aed"
|
||||
}
|
||||
var maxPos int
|
||||
d.GDB.Model(&Category{}).Select("COALESCE(MAX(position), 0)").Scan(&maxPos)
|
||||
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon, Position: maxPos + 1}
|
||||
if err := d.GDB.Create(&c).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCategory(id int, name, color string, isComingSoon bool) (*Category, error) {
|
||||
if color == "" {
|
||||
color = "#7c3aed"
|
||||
}
|
||||
var c Category
|
||||
if err := d.GDB.First(&c, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.GDB.Model(&c).Updates(map[string]interface{}{"name": name, "color": color, "is_coming_soon": isComingSoon}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteCategory(id int) error {
|
||||
var count int64
|
||||
d.GDB.Table("products").
|
||||
Where("category = (SELECT name FROM categories WHERE id = ?)", id).
|
||||
Count(&count)
|
||||
if count > 0 {
|
||||
return fmt.Errorf("catégorie utilisée par %d produit(s)", count)
|
||||
}
|
||||
result := d.GDB.Delete(&Category{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("catégorie non trouvée")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReorderCategories met à jour les positions selon l'ordre du tableau d'IDs fourni.
|
||||
func (d *Database) ReorderCategories(ids []int) error {
|
||||
tx := d.GDB.Begin()
|
||||
for i, id := range ids {
|
||||
if err := tx.Model(&Category{}).Where("id = ?", id).Update("position", i+1).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func (d *Database) CategoryExists(name string) (bool, error) {
|
||||
var count int64
|
||||
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -1,825 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) CreateClient(client *models.Client) error {
|
||||
var result struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO clients (username, password, nom, prenom, telephone, command, amende, must_change_password, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 0.0, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, created_at`,
|
||||
client.Username, client.Password, client.Nom, client.Prenom, client.Telephone, client.MustChangePassword,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la création du client: %w", err)
|
||||
}
|
||||
client.ID = result.ID
|
||||
client.CreatedAt = result.CreatedAt
|
||||
|
||||
log.Printf("✅ Client créé avec succès: %s %s (ID: %d)", client.Prenom, client.Nom, client.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientByID récupère un client par son ID
|
||||
func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende,
|
||||
COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
|
||||
FROM clients WHERE id = ?`, id).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
client := &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
client.PointsExtra = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetAllClients récupère tous les clients
|
||||
func (d *Database) GetAllClients() ([]*models.Client, error) {
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
ReferralBalance float64 `gorm:"column:referral_balance"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende, referral_balance,
|
||||
COALESCE(cancellations_count, 0) as cancellations_count,
|
||||
COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
|
||||
FROM clients ORDER BY created_at DESC`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des clients: %w", err)
|
||||
}
|
||||
|
||||
clients := make([]*models.Client, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
client := &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
ReferralBalance: row.ReferralBalance,
|
||||
CancellationsCount: row.CancellationsCount,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
client.PointsExtra = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// UpdateClient met à jour un client existant
|
||||
func (d *Database) UpdateClient(client *models.Client) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("id = ?", client.ID).Updates(map[string]any{
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"command": client.Command,
|
||||
"amende": client.Amende,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du client: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteClient supprime un client
|
||||
func (d *Database) DeleteClient(id int) error {
|
||||
_ = d.RevokeAllUserTokens(id, "client")
|
||||
|
||||
result := d.GDB.Delete(&models.Client{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression du client: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateClientPassword met à jour le mot de passe d'un client
|
||||
func (d *Database) UpdateClientPassword(clientID int, hashedPassword string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("password", hashedPassword)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateClientPasswordAndClearFlag met à jour le mot de passe et remet must_change_password à false
|
||||
func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Updates(map[string]any{
|
||||
"password": hashedPassword,
|
||||
"must_change_password": false,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientAmende(username string) (float64, error) {
|
||||
var result struct {
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COALESCE(amende, 0) as amende FROM clients WHERE username = ?`, username).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetClientAmende] Erreur pour %s: %v", username, err)
|
||||
return 0, fmt.Errorf("erreur récupération pénalités: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("💰 [GetClientAmende] Client %s: %.2f points", username, result.Amende)
|
||||
return result.Amende, nil
|
||||
}
|
||||
|
||||
// IncrementClientCommandCount incrémente le compteur de commandes du client
|
||||
func (d *Database) IncrementClientCommandCount(username string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1"))
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'incrémentation du compteur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AddClientPointsByCategory(username string, points int, poolKey string) error {
|
||||
if poolKey == "" {
|
||||
poolKey = "pool_0"
|
||||
}
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE clients
|
||||
SET points_extra = jsonb_set(
|
||||
COALESCE(points_extra, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(COALESCE((points_extra->>?)::int, 0) + ?)
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`,
|
||||
poolKey, poolKey, points, username)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'ajout de points: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
log.Printf("✅ %d points (key=%s) ajoutés au client %s", points, poolKey, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CalculateAndAddPointsForCommand(commandID int, username string) (int, error) {
|
||||
var totalPoints int
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalPoints = points
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalPoints, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende, created_at
|
||||
FROM clients WHERE telephone = ?`, telephone).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClientByUsername récupère un client par son username
|
||||
// GetClientsByUsernames charge plusieurs clients en une seule requête.
|
||||
// Retourne map[username]*Client ; les usernames sans correspondance sont absents de la map.
|
||||
func (d *Database) GetClientsByUsernames(usernames []string) (map[string]*models.Client, error) {
|
||||
result := make(map[string]*models.Client, len(usernames))
|
||||
if len(usernames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT id, username, nom, prenom FROM clients WHERE username IN ?`, usernames).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
result[r.Username] = &models.Client{ID: r.ID, Username: r.Username, Nom: r.Nom, Prenom: r.Prenom}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
MustChangePassword bool `gorm:"column:must_change_password"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
TwoFAEnabled bool `gorm:"column:two_fa_enabled"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende,
|
||||
must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at,
|
||||
two_fa_enabled
|
||||
FROM clients WHERE username = ?`, username).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
client := &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
MustChangePassword: row.MustChangePassword,
|
||||
CreatedAt: row.CreatedAt,
|
||||
TwoFAEnabled: row.TwoFAEnabled,
|
||||
}
|
||||
client.PointsExtra = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (d *Database) SetClientTwoFAEnabled(clientID int, enabled bool) error {
|
||||
return d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("two_fa_enabled", enabled).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]any, error) {
|
||||
amende, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cancellationsCount, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup annulations: %v", err)
|
||||
cancellationsCount = 0
|
||||
}
|
||||
|
||||
cancellationHistory, err := d.GetClientCancellationHistory(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
|
||||
cancellationHistory = map[string]any{
|
||||
"cancellations_count": cancellationsCount,
|
||||
"next_penalty": 20,
|
||||
}
|
||||
}
|
||||
|
||||
info := map[string]any{
|
||||
"username": username,
|
||||
"total_penalty": amende,
|
||||
"cancellations_count": cancellationsCount,
|
||||
"cancellation_history": cancellationHistory,
|
||||
"has_penalties": amende > 0,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ResetClientPoint réinitialise les points d'un client.
|
||||
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
|
||||
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra
|
||||
func (d *Database) ResetClientPoint(username string, poolIdx int, extraPoolKey string) error {
|
||||
if extraPoolKey != "" {
|
||||
err := d.GDB.Exec(`
|
||||
UPDATE clients SET points_extra = points_extra - ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, extraPoolKey, username).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE extra: %v", err)
|
||||
} else {
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// -1 ou poolIdx sans clé → reset total
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE clients SET points_extra = '{}'::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur reset points: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientPenalties(username string, _ bool) error {
|
||||
log.Printf("🔄 [ResetClientPenalties] Reset amende + cancellations_count pour %s", username)
|
||||
|
||||
result := d.GDB.Exec(
|
||||
`UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`,
|
||||
username,
|
||||
)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur reset pénalités: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
|
||||
var rows []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||
UpdatedAt any `gorm:"column:updated_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
|
||||
FROM clients
|
||||
WHERE amende > 0
|
||||
ORDER BY amende DESC`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetAllClientsWithPenalties] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération clients: %w", err)
|
||||
}
|
||||
|
||||
clients := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
clients = append(clients, map[string]any{
|
||||
"username": row.Username,
|
||||
"total_penalty": row.Amende,
|
||||
"cancellations_count": row.CancellationsCount,
|
||||
"last_updated": row.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetAllClientsWithPenalties] %d clients avec pénalités", len(clients))
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesStats() (map[string]any, error) {
|
||||
var result struct {
|
||||
ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
|
||||
TotalPenalties float64 `gorm:"column:total_penalties"`
|
||||
AvgPenalty float64 `gorm:"column:avg_penalty"`
|
||||
MaxPenalty float64 `gorm:"column:max_penalty"`
|
||||
TotalClients int `gorm:"column:total_clients"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
COUNT(CASE WHEN amende > 0 THEN 1 END) as clients_with_penalties,
|
||||
COALESCE(SUM(amende), 0) as total_penalties,
|
||||
COALESCE(AVG(amende), 0) as avg_penalty,
|
||||
COALESCE(MAX(amende), 0) as max_penalty,
|
||||
COUNT(*) as total_clients
|
||||
FROM clients`).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetClientPenaltiesStats] Erreur: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]any{
|
||||
"clients_with_penalties": result.ClientsWithPenalties,
|
||||
"total_penalties": result.TotalPenalties,
|
||||
"average_penalty": result.AvgPenalty,
|
||||
"max_penalty": result.MaxPenalty,
|
||||
"total_clients": result.TotalClients,
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetClientPenaltiesStats] Stats: %d/%d clients avec pénalités",
|
||||
result.ClientsWithPenalties, result.TotalClients)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (d *Database) CalculateAndAddPointsForCommandTx(tx *gorm.DB, commandID int, username string) (int, string, error) {
|
||||
log.Printf("💰 [CalcPointsTx] START - cmd=%d, user=%s", commandID, username)
|
||||
|
||||
// Charger les paramètres globaux
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur lecture settings, utilisation des défauts: %v", err)
|
||||
settings = DefaultSettings()
|
||||
}
|
||||
|
||||
pools := settings.PointsPools
|
||||
if len(pools) == 0 {
|
||||
log.Printf("ℹ️ [CalcPointsTx] Aucun pool configuré → 0 points")
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
// Construire la map catégorie → index de pool
|
||||
catToPool := make(map[string]int)
|
||||
for i, pool := range pools {
|
||||
for _, cat := range pool.Categories {
|
||||
catToPool[strings.ToLower(cat)] = i
|
||||
}
|
||||
}
|
||||
|
||||
if len(catToPool) == 0 {
|
||||
log.Printf("ℹ️ [CalcPointsTx] Aucune catégorie assignée aux pools → 0 points")
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 1: Récupérer tous les items de la commande avec leurs catégories
|
||||
var items []struct {
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
Category string `gorm:"column:category"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT ci.quantite, ci.prix, COALESCE(p.category, '') as category
|
||||
FROM command_items ci
|
||||
LEFT JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id = ?
|
||||
`, commandID).Scan(&items).Error; err != nil {
|
||||
log.Printf("❌ [CalcPointsTx] Erreur query items: %v", err)
|
||||
return 0, "", fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
log.Printf("⚠️ [CalcPointsTx] Aucun item trouvé pour cmd %d", commandID)
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
poolTotals := make([]float64, len(pools))
|
||||
for _, item := range items {
|
||||
catLower := strings.ToLower(item.Category)
|
||||
if poolIdx, ok := catToPool[catLower]; ok {
|
||||
poolTotals[poolIdx] += item.Prix
|
||||
}
|
||||
}
|
||||
|
||||
for i, t := range poolTotals {
|
||||
log.Printf("📊 [CalcPointsTx] Pool[%d] (%s): %.2f€", i, pools[i].Name, t)
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 2: Calculer les points pour tous les pools
|
||||
var totalPoints int
|
||||
var pointCategory string
|
||||
|
||||
poolPts := make([]int, len(pools))
|
||||
var categoryParts []string
|
||||
for i, pool := range pools {
|
||||
poolPts[i] = CalcPointsFromTiers(poolTotals[i], pool.Tiers)
|
||||
totalPoints += poolPts[i]
|
||||
if poolPts[i] > 0 {
|
||||
categoryParts = append(categoryParts, pool.Name)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("💰 [CalcPointsTx] points par pool: %v, total=%d", poolPts, totalPoints)
|
||||
|
||||
if totalPoints == 0 {
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
if len(categoryParts) > 0 {
|
||||
pointCategory = strings.Join(categoryParts, " & ")
|
||||
} else {
|
||||
pointCategory = "points"
|
||||
}
|
||||
|
||||
for i, pool := range pools {
|
||||
if poolPts[i] == 0 {
|
||||
continue
|
||||
}
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_extra = jsonb_set(
|
||||
COALESCE(points_extra, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(COALESCE((points_extra->>?)::int, 0) + ?)
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
`, pool.Key, pool.Key, poolPts[i], username).Error; err != nil {
|
||||
log.Printf("❌ [CalcPointsTx] Erreur UPDATE points_extra pool[%d] (%s): %v", i, pool.Key, err)
|
||||
return 0, "", fmt.Errorf("erreur mise à jour points pool[%d]: %w", i, err)
|
||||
}
|
||||
log.Printf("💰 [CalcPointsTx] pool[%d] (%s / key=%s): +%d pts", i, pool.Name, pool.Key, poolPts[i])
|
||||
}
|
||||
|
||||
log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username)
|
||||
|
||||
// ✅ ÉTAPE 3: Déduire les points des récompenses reçues dans cette commande
|
||||
var rewardItems []struct {
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT reward_pool_key FROM command_items
|
||||
WHERE command_id = ? AND is_reward = true AND reward_pool_key != ''
|
||||
`, commandID).Scan(&rewardItems).Error; err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur query reward items: %v", err)
|
||||
}
|
||||
|
||||
for _, ri := range rewardItems {
|
||||
if settings.PointsReward == nil || settings.PointsReward.Threshold <= 0 {
|
||||
break
|
||||
}
|
||||
threshold := settings.PointsReward.Threshold
|
||||
poolKey := ri.RewardPoolKey
|
||||
// Déduire threshold points de points_extra[poolKey] (plancher à 0)
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_extra = jsonb_set(
|
||||
COALESCE(points_extra, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(GREATEST(0, COALESCE((points_extra->>?)::int, 0) - ?))
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
`, poolKey, poolKey, threshold, username).Error; err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur déduction points reward pool=%s: %v", poolKey, err)
|
||||
} else {
|
||||
log.Printf("🎁 [CalcPointsTx] Récompense reçue: -%d pts pool=%s → %s", threshold, poolKey, username)
|
||||
}
|
||||
// Décrémenter points_redeemed[poolKey] (plancher à 0)
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_redeemed = jsonb_set(
|
||||
COALESCE(points_redeemed, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(GREATEST(0, COALESCE((points_redeemed->>?)::int, 0) - 1))
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
`, poolKey, poolKey, username).Error; err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur décrément redeemed pool=%s: %v", poolKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
return totalPoints, pointCategory, nil
|
||||
}
|
||||
|
||||
func (d *Database) CanUserAccessCommand(
|
||||
commandID int,
|
||||
username string,
|
||||
role string,
|
||||
) (bool, error) {
|
||||
|
||||
// 👑 Admin : accès total
|
||||
if role == "admin" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
// 🚚 Livreur : seulement commandes assignées
|
||||
if role == "livreur" {
|
||||
err := d.GDB.Raw(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM commandes
|
||||
WHERE id = ? AND livreur_assign = ?
|
||||
)
|
||||
`, commandID, username).Scan(&exists).Error
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// 👤 User : seulement SES commandes
|
||||
err := d.GDB.Raw(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM commandes
|
||||
WHERE id = ? AND username = ?
|
||||
)
|
||||
`, commandID, username).Scan(&exists).Error
|
||||
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// GetClientPointsAndRewards retourne les points cumulés et les récompenses réclamées pour un client.
|
||||
func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[string]int, pointsRedeemed map[string]int, err error) {
|
||||
var row struct {
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
PointsRedeemedJSON []byte `gorm:"column:points_redeemed"`
|
||||
}
|
||||
if err = d.GDB.Raw(`
|
||||
SELECT COALESCE(points_extra, '{}'::jsonb) as points_extra,
|
||||
COALESCE(points_redeemed, '{}'::jsonb) as points_redeemed
|
||||
FROM clients WHERE username = ?`, username).Scan(&row).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("erreur lecture points client: %w", err)
|
||||
}
|
||||
pointsExtra = map[string]int{}
|
||||
pointsRedeemed = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &pointsExtra)
|
||||
}
|
||||
if len(row.PointsRedeemedJSON) > 0 {
|
||||
json.Unmarshal(row.PointsRedeemedJSON, &pointsRedeemed)
|
||||
}
|
||||
return pointsExtra, pointsRedeemed, nil
|
||||
}
|
||||
|
||||
// claimPoolRewardTx vérifie l'éligibilité et consomme une récompense pour un
|
||||
// pool donné, dans la transaction fournie — factorisée pour être appelée
|
||||
// seule (ClaimPoolReward) ou combinée avec la livraison du produit dans la
|
||||
// même transaction (ClaimPoolRewardAndAddToBasket), afin qu'une récompense
|
||||
// ne soit jamais consommée sans que son produit soit effectivement livré.
|
||||
func claimPoolRewardTx(tx *gorm.DB, username, poolKey string, threshold int) (remainingAvailable int, err error) {
|
||||
var row struct {
|
||||
Points int `gorm:"column:pts"`
|
||||
Redeemed int `gorm:"column:redeemed"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT
|
||||
COALESCE((points_extra->>?)::int, 0) as pts,
|
||||
COALESCE((points_redeemed->>?)::int, 0) as redeemed
|
||||
FROM clients WHERE username = ? FOR UPDATE`,
|
||||
poolKey, poolKey, username).Scan(&row).Error; err != nil {
|
||||
return 0, fmt.Errorf("erreur lecture: %w", err)
|
||||
}
|
||||
|
||||
earned := row.Points / threshold
|
||||
available := earned - row.Redeemed
|
||||
if available <= 0 {
|
||||
return 0, fmt.Errorf("pas de récompense disponible pour ce pool")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_redeemed = jsonb_set(
|
||||
COALESCE(points_redeemed, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`,
|
||||
poolKey, poolKey, username).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return earned - (row.Redeemed + 1), nil
|
||||
}
|
||||
|
||||
// ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points.
|
||||
// Retourne le nombre de récompenses disponibles restantes après la réclamation.
|
||||
func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) {
|
||||
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return remainingAvailable, nil
|
||||
}
|
||||
|
||||
func (d *Database) ClaimPoolRewardAndAddToBasket(username, poolKey string, threshold int, items []models.RewardItem) (remainingAvailable int, added []models.Panier, err error) {
|
||||
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) > 0 {
|
||||
added, err = addRewardsToBasketTx(tx, username, items, poolKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return remainingAvailable, added, nil
|
||||
}
|
||||
|
||||
// ResetClientRedeemed remet à zéro les récompenses réclamées (admin).
|
||||
func (d *Database) ResetClientRedeemed(username, poolKey string) error {
|
||||
if poolKey != "" {
|
||||
return d.GDB.Exec(`
|
||||
UPDATE clients SET points_redeemed = points_redeemed - ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, poolKey, username).Error
|
||||
}
|
||||
return d.GDB.Exec(`
|
||||
UPDATE clients SET points_redeemed = '{}'::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username).Error
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// commandItemFull mappe toutes les colonnes de command_items pour les insertions batch avec infos client.
|
||||
type commandItemFull struct {
|
||||
CommandID int `gorm:"column:command_id"`
|
||||
Produit string `gorm:"column:produit"`
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
ClientTelephone string `gorm:"column:client_telephone"`
|
||||
DeliveryAddress string `gorm:"column:delivery_address"`
|
||||
Status string `gorm:"column:status"`
|
||||
}
|
||||
|
||||
func (commandItemFull) TableName() string { return "command_items" }
|
||||
|
||||
// InsertCommandItemsBatch insère plusieurs items en une seule requête.
|
||||
func (d *Database) InsertCommandItemsBatch(items []commandItemFull) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.GDB.Create(&items).Error
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VALIDATION HELPERS
|
||||
// ============================================
|
||||
|
||||
func validateCommandID(commandID int) error {
|
||||
if commandID <= 0 {
|
||||
return fmt.Errorf("ID commande invalide: %d", commandID)
|
||||
}
|
||||
if commandID > 2147483647 {
|
||||
return fmt.Errorf("ID commande trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateItemID(itemID int) error {
|
||||
if itemID <= 0 {
|
||||
return fmt.Errorf("ID item invalide: %d", itemID)
|
||||
}
|
||||
if itemID > 2147483647 {
|
||||
return fmt.Errorf("ID item trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func validateQuantite(quantite float64) error {
|
||||
if quantite <= 0 {
|
||||
return fmt.Errorf("quantité doit être > 0")
|
||||
}
|
||||
if quantite > 10000 {
|
||||
return fmt.Errorf("quantité trop élevée (max 10000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePrix(prix float64) error {
|
||||
if prix <= 0 {
|
||||
return fmt.Errorf("prix doit être > 0")
|
||||
}
|
||||
if prix > 100000 {
|
||||
return fmt.Errorf("prix trop élevé (max 100000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUsername(username string) error {
|
||||
if len(username) == 0 {
|
||||
return fmt.Errorf("username vide")
|
||||
}
|
||||
if len(username) > 100 {
|
||||
return fmt.Errorf("username trop long (max 100)")
|
||||
}
|
||||
// Sanitize
|
||||
if strings.Contains(username, "..") || strings.Contains(username, "/") {
|
||||
return fmt.Errorf("username invalide")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeliveryAddress(address string) error {
|
||||
if len(address) == 0 {
|
||||
return fmt.Errorf("adresse vide")
|
||||
}
|
||||
if len(address) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateItemStatus(status string) error {
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled"}
|
||||
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
|
||||
if !slices.Contains(validStatuses, status) {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INSERT COMMAND ITEM - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (d *Database) InsertCommandItemWithClientInfo(
|
||||
commandID int,
|
||||
produit string,
|
||||
productID int,
|
||||
quantite float64,
|
||||
prix float64,
|
||||
isReward bool,
|
||||
rewardPoolKey string,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
|
||||
) error {
|
||||
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
|
||||
|
||||
// ✅ VALIDATION COMPLÈTE
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateQuantite(quantite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Les articles récompense ont prix=0, on saute la validation de prix pour eux
|
||||
if !isReward {
|
||||
if err := validatePrix(prix); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateUsername(clientUsername); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateDeliveryAddress(deliveryAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ SANITIZE STRINGS
|
||||
produit = strings.TrimSpace(produit)
|
||||
if len(produit) > 200 {
|
||||
produit = produit[:200]
|
||||
}
|
||||
|
||||
clientNom = strings.TrimSpace(clientNom)
|
||||
if len(clientNom) > 100 {
|
||||
clientNom = clientNom[:100]
|
||||
}
|
||||
|
||||
clientPrenom = strings.TrimSpace(clientPrenom)
|
||||
if len(clientPrenom) > 100 {
|
||||
clientPrenom = clientPrenom[:100]
|
||||
}
|
||||
|
||||
clientTelephone = strings.TrimSpace(clientTelephone)
|
||||
if len(clientTelephone) > 20 {
|
||||
clientTelephone = clientTelephone[:20]
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LA COMMANDE EXISTE
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM commandes WHERE id = ?)`, commandID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ Erreur vérification commande: %v", err)
|
||||
return fmt.Errorf("erreur vérification commande: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("commande %d n'existe pas", commandID)
|
||||
}
|
||||
|
||||
// ✅ INSERT
|
||||
err := d.GDB.Exec(`
|
||||
INSERT INTO command_items (
|
||||
command_id, produit, product_id, quantite, prix,
|
||||
is_reward, reward_pool_key,
|
||||
client_username, client_nom, client_prenom, client_telephone, delivery_address,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
commandID, produit, productID, quantite, prix,
|
||||
isReward, rewardPoolKey,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
|
||||
).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur INSERT command_items: %v", err)
|
||||
return fmt.Errorf("erreur insertion item: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Item inséré avec infos client: %s %s", clientNom, clientPrenom)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL
|
||||
// ============================================
|
||||
|
||||
func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
log.Printf("❌ [GetCommandItems] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CommandID int `gorm:"column:command_id"`
|
||||
Produit string `gorm:"column:produit"`
|
||||
ProductID *int64 `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
ClientTelephone string `gorm:"column:client_telephone"`
|
||||
DeliveryAddress *string `gorm:"column:delivery_address"`
|
||||
Status *string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
CommandStatus *string `gorm:"column:command_status"`
|
||||
CommandAddress *string `gorm:"column:command_address"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||
Category string `gorm:"column:category"`
|
||||
Unit string `gorm:"column:unit"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
ci.id,
|
||||
ci.command_id,
|
||||
ci.produit,
|
||||
ci.product_id,
|
||||
ci.quantite,
|
||||
ci.prix,
|
||||
ci.is_reward,
|
||||
ci.reward_pool_key,
|
||||
ci.client_username,
|
||||
ci.client_nom,
|
||||
ci.client_prenom,
|
||||
ci.client_telephone,
|
||||
ci.delivery_address,
|
||||
ci.status,
|
||||
ci.created_at,
|
||||
ci.updated_at,
|
||||
c.status as command_status,
|
||||
c.adresse as command_address,
|
||||
c.total_prix,
|
||||
c.referral_used,
|
||||
c.livreur_assign,
|
||||
c.created_at as command_created_at,
|
||||
COALESCE(p.category, '') as category,
|
||||
COALESCE(p.unit, '') as unit,
|
||||
c.client_order_id as client_order_number
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
LEFT JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id = ?
|
||||
ORDER BY ci.id ASC`, commandID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
productIDValue := 0
|
||||
if row.ProductID != nil {
|
||||
productIDValue = int(*row.ProductID)
|
||||
}
|
||||
|
||||
var commandCreatedAt any
|
||||
if row.CommandCreatedAt != nil {
|
||||
commandCreatedAt = *row.CommandCreatedAt
|
||||
}
|
||||
|
||||
item := map[string]any{
|
||||
"id": row.ID,
|
||||
"command_id": row.CommandID,
|
||||
"produit": row.Produit,
|
||||
"product_id": productIDValue,
|
||||
"quantite": row.Quantite,
|
||||
"prix": row.Prix,
|
||||
"is_reward": row.IsReward,
|
||||
"reward_pool_key": row.RewardPoolKey,
|
||||
"client_username": row.ClientUsername,
|
||||
"client_nom": row.ClientNom,
|
||||
"client_prenom": row.ClientPrenom,
|
||||
"client_telephone": row.ClientTelephone,
|
||||
"delivery_address": ptrStr(row.DeliveryAddress),
|
||||
"status": ptrStr(row.Status),
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
// Infos commande
|
||||
"command_status": ptrStr(row.CommandStatus),
|
||||
"command_address": ptrStr(row.CommandAddress),
|
||||
"total_prix": row.TotalPrix,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"livreur_assign": ptrStr(row.LivreurAssign),
|
||||
"command_created_at": commandCreatedAt,
|
||||
"category": row.Category,
|
||||
"unit": row.Unit,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
log.Printf("✅ %d items récupérés avec infos client et catégories", len(items))
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetCommandItemsBatch charge les items de plusieurs commandes en une seule requête.
|
||||
// Retourne map[commandID][]items, même structure que GetCommandItems.
|
||||
func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]any, error) {
|
||||
result := make(map[int][]map[string]any, len(commandIDs))
|
||||
if len(commandIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CommandID int `gorm:"column:command_id"`
|
||||
Produit string `gorm:"column:produit"`
|
||||
ProductID *int64 `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
ClientTelephone string `gorm:"column:client_telephone"`
|
||||
DeliveryAddress *string `gorm:"column:delivery_address"`
|
||||
Status *string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
CommandStatus *string `gorm:"column:command_status"`
|
||||
CommandAddress *string `gorm:"column:command_address"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||
Category string `gorm:"column:category"`
|
||||
Unit string `gorm:"column:unit"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
ci.id, ci.command_id, ci.produit, ci.product_id,
|
||||
ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key,
|
||||
ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone,
|
||||
ci.delivery_address, ci.status, ci.created_at, ci.updated_at,
|
||||
c.status as command_status, c.adresse as command_address,
|
||||
c.total_prix, c.referral_used, c.livreur_assign,
|
||||
c.created_at as command_created_at,
|
||||
COALESCE(p.category, '') as category,
|
||||
COALESCE(p.unit, '') as unit,
|
||||
c.client_order_id as client_order_number
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
LEFT JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id IN ?
|
||||
ORDER BY ci.command_id ASC, ci.id ASC`, commandIDs).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération items batch: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
productIDValue := 0
|
||||
if row.ProductID != nil {
|
||||
productIDValue = int(*row.ProductID)
|
||||
}
|
||||
var commandCreatedAt any
|
||||
if row.CommandCreatedAt != nil {
|
||||
commandCreatedAt = *row.CommandCreatedAt
|
||||
}
|
||||
item := map[string]any{
|
||||
"id": row.ID, "command_id": row.CommandID,
|
||||
"produit": row.Produit, "product_id": productIDValue,
|
||||
"quantite": row.Quantite, "prix": row.Prix,
|
||||
"is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey,
|
||||
"client_username": row.ClientUsername, "client_nom": row.ClientNom,
|
||||
"client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone,
|
||||
"delivery_address": ptrStr(row.DeliveryAddress), "status": ptrStr(row.Status),
|
||||
"created_at": row.CreatedAt, "updated_at": row.UpdatedAt,
|
||||
"command_status": ptrStr(row.CommandStatus), "command_address": ptrStr(row.CommandAddress),
|
||||
"total_prix": row.TotalPrix, "referral_used": row.ReferralUsed,
|
||||
"livreur_assign": ptrStr(row.LivreurAssign), "command_created_at": commandCreatedAt,
|
||||
"category": row.Category, "unit": row.Unit,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
}
|
||||
result[row.CommandID] = append(result[row.CommandID], item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ptrStr retourne la valeur d'un *string ou "" si nil
|
||||
func ptrStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// DeleteCommandItem supprime un item d'une commande et restaure son stock si
|
||||
// la commande n'est pas déjà dans un état terminal. Le statut de la commande
|
||||
// est verrouillé (FOR UPDATE) avant toute décision, dans la même transaction
|
||||
// que la suppression et le remboursement, pour éviter une course avec une
|
||||
// annulation concurrente de la commande entière (qui rembourserait déjà cet
|
||||
// item) — même classe de bug que celle corrigée sur UpdateCommandStatusAdmin.
|
||||
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
||||
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
|
||||
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateItemID(itemID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdStatus string
|
||||
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
|
||||
return fmt.Errorf("erreur vérification commande: %w", err)
|
||||
}
|
||||
if cmdStatus == "" {
|
||||
return fmt.Errorf("commande %d non trouvée", commandID)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
}
|
||||
if err := tx.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
|
||||
return fmt.Errorf("erreur vérification item: %w", err)
|
||||
}
|
||||
if result.Prix == 0 && result.Quantite == 0 {
|
||||
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
|
||||
log.Printf("❌ Erreur DELETE command_items: %v", err)
|
||||
return fmt.Errorf("erreur suppression item: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(
|
||||
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
|
||||
result.Prix*result.Quantite, commandID,
|
||||
).Error; err != nil {
|
||||
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour total commande: %w", err)
|
||||
}
|
||||
|
||||
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
|
||||
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
|
||||
if restoreStock {
|
||||
if err := tx.Exec(
|
||||
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
result.Quantite, result.ProductID,
|
||||
).Error; err != nil {
|
||||
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
|
||||
return fmt.Errorf("erreur restauration stock: %w", err)
|
||||
}
|
||||
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
|
||||
if err := validateItemID(itemID); err != nil {
|
||||
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateItemStatus(status); err != nil {
|
||||
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM command_items WHERE id = ?)`, itemID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification item: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ Item %d non trouvé", itemID)
|
||||
return fmt.Errorf("item %d non trouvé", itemID)
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE command_items
|
||||
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, status, itemID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ Item %d non trouvé", itemID)
|
||||
return fmt.Errorf("item non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// ============================================
|
||||
// db/commands_priority.go
|
||||
// 🎯 SYSTÈME DE PRIORISATION DES COMMANDES
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// GetAllCommandsOldestFirst récupère les commandes triées par ancienneté (plus anciennes en premier)
|
||||
func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[string]any, error) {
|
||||
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.livreur_assign, c.created_at, c.updated_at
|
||||
FROM commandes c
|
||||
WHERE 1=1`
|
||||
|
||||
args := []any{}
|
||||
|
||||
if status != "" {
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled"}
|
||||
if !slices.Contains(validStatuses, status) {
|
||||
return nil, fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
query += " AND c.status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
query += " AND c.username = ?"
|
||||
args = append(args, username)
|
||||
}
|
||||
|
||||
query += " ORDER BY c.created_at ASC"
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commandes prioritaires: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetPendingCommandsStats récupère des statistiques sur les commandes en attente
|
||||
func (d *Database) GetPendingCommandsStats() (map[string]any, error) {
|
||||
var result struct {
|
||||
TotalPending int `gorm:"column:total_pending"`
|
||||
AvgWaitingSeconds *float64 `gorm:"column:avg_waiting_seconds"`
|
||||
OldestCommandDate *string `gorm:"column:oldest_command_date"`
|
||||
NewestCommandDate *string `gorm:"column:newest_command_date"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
COUNT(*) as total_pending,
|
||||
AVG(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))) as avg_waiting_seconds,
|
||||
MIN(created_at) as oldest_command_date,
|
||||
MAX(created_at) as newest_command_date
|
||||
FROM commandes
|
||||
WHERE status = 'pending'`).Scan(&result).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]any{
|
||||
"total_pending": result.TotalPending,
|
||||
"avg_waiting_seconds": 0,
|
||||
"avg_waiting_minutes": 0,
|
||||
"oldest_command_date": nil,
|
||||
"newest_command_date": nil,
|
||||
"oldest_waiting_minutes": 0,
|
||||
}
|
||||
|
||||
if result.AvgWaitingSeconds != nil {
|
||||
stats["avg_waiting_seconds"] = int(*result.AvgWaitingSeconds)
|
||||
stats["avg_waiting_minutes"] = int(*result.AvgWaitingSeconds / 60)
|
||||
}
|
||||
|
||||
if result.OldestCommandDate != nil {
|
||||
stats["oldest_command_date"] = *result.OldestCommandDate
|
||||
}
|
||||
|
||||
if result.NewestCommandDate != nil {
|
||||
stats["newest_command_date"] = *result.NewestCommandDate
|
||||
}
|
||||
|
||||
log.Printf("📊 [STATS] Commandes pending: %d | Attente moyenne: %d min",
|
||||
result.TotalPending, stats["avg_waiting_minutes"])
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -1,980 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// errAlreadyApproved est retournée quand le client tente d'approuver une commande déjà approuvée.
|
||||
var errAlreadyApproved = errors.New("already_approved")
|
||||
|
||||
func sanitizeString(s string) string {
|
||||
sanitized := strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
|
||||
if len(sanitized) > 1000 {
|
||||
sanitized = sanitized[:1000]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sanitized)
|
||||
}
|
||||
|
||||
func validateAddress(address string) error {
|
||||
address = strings.TrimSpace(address)
|
||||
|
||||
if address == "" {
|
||||
return fmt.Errorf("adresse vide non autorisée")
|
||||
}
|
||||
|
||||
if len(address) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||||
}
|
||||
|
||||
if strings.ContainsAny(address, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0E\x0F") {
|
||||
return fmt.Errorf("adresse contient des caractères de contrôle interdits")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type basketItem struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
}
|
||||
|
||||
// validateCommandStatus vérifie si le statut est valide
|
||||
func validateCommandStatus(status string) error {
|
||||
validStatuses := map[string]bool{
|
||||
"pending": true,
|
||||
"assigned": true,
|
||||
"en_route": true,
|
||||
"arrived": true,
|
||||
"livre": true,
|
||||
"approved": true,
|
||||
"cancelled": true,
|
||||
"disabled": true,
|
||||
}
|
||||
|
||||
if !validStatuses[status] {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateAddress(deliveryAddress); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := d.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Client non trouvé: %v", err)
|
||||
}
|
||||
|
||||
clientNom := ""
|
||||
clientPrenom := ""
|
||||
clientTelephone := ""
|
||||
if client != nil {
|
||||
clientNom = sanitizeString(client.Nom)
|
||||
clientPrenom = sanitizeString(client.Prenom)
|
||||
clientTelephone = sanitizeString(client.Telephone)
|
||||
}
|
||||
|
||||
var (
|
||||
command *models.Command
|
||||
totalPrix float64
|
||||
)
|
||||
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
// Verrou sur le panier : un double-submit concurrent du même client se
|
||||
// bloque ici puis échoue proprement ("panier vide") une fois le premier
|
||||
// passage terminé, au lieu de créer une commande fantôme.
|
||||
var basketItems []basketItem
|
||||
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
|
||||
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
if len(basketItems) == 0 {
|
||||
return fmt.Errorf("le panier est vide")
|
||||
}
|
||||
|
||||
for _, item := range basketItems {
|
||||
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
|
||||
return fmt.Errorf("données panier invalides")
|
||||
}
|
||||
totalPrix += item.Price
|
||||
}
|
||||
|
||||
if totalPrix <= 0 || totalPrix > 100000 {
|
||||
return fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
|
||||
}
|
||||
|
||||
var cmdResult struct {
|
||||
ID int `gorm:"column:id"`
|
||||
ClientOrderID int `gorm:"column:client_order_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, client_order_id, created_at, updated_at`,
|
||||
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error; err != nil {
|
||||
return fmt.Errorf("erreur création commande: %w", err)
|
||||
}
|
||||
commandID := cmdResult.ID
|
||||
|
||||
productIDs2 := make([]int, 0, len(basketItems))
|
||||
for _, item := range basketItems {
|
||||
productIDs2 = append(productIDs2, item.ProductID)
|
||||
}
|
||||
productNames2, _ := d.GetProductNamesByIDs(productIDs2)
|
||||
|
||||
batchItems := make([]commandItemFull, 0, len(basketItems))
|
||||
for _, item := range basketItems {
|
||||
productName := productNames2[item.ProductID]
|
||||
if productName == "" {
|
||||
productName = fmt.Sprintf("Produit #%d", item.ProductID)
|
||||
}
|
||||
batchItems = append(batchItems, commandItemFull{
|
||||
CommandID: commandID,
|
||||
Produit: productName,
|
||||
ProductID: item.ProductID,
|
||||
Quantite: item.Quantity,
|
||||
Prix: item.Price,
|
||||
IsReward: item.IsReward,
|
||||
RewardPoolKey: item.RewardPoolKey,
|
||||
ClientUsername: username,
|
||||
ClientNom: clientNom,
|
||||
ClientPrenom: clientPrenom,
|
||||
ClientTelephone: clientTelephone,
|
||||
DeliveryAddress: deliveryAddress,
|
||||
Status: "pending",
|
||||
})
|
||||
}
|
||||
if err := tx.Create(&batchItems).Error; err != nil {
|
||||
return fmt.Errorf("erreur insertion items: %w", err)
|
||||
}
|
||||
|
||||
// Les articles récompense (payés en points) restent des produits physiques
|
||||
// réellement distribués : le stock doit être décrémenté comme pour un
|
||||
// article payant.
|
||||
for _, item := range basketItems {
|
||||
var currentStock float64
|
||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(¤tStock).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
|
||||
}
|
||||
if currentStock < item.Quantity {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
|
||||
}
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
command = &models.Command{
|
||||
ID: commandID,
|
||||
ClientOrderID: cmdResult.ClientOrderID,
|
||||
Username: username,
|
||||
Status: "pending",
|
||||
Total: totalPrix,
|
||||
DeliveryAddress: deliveryAddress,
|
||||
CreatedAt: cmdResult.CreatedAt,
|
||||
UpdatedAt: cmdResult.UpdatedAt,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur création commande: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sanitizedAddress := sanitizeLogMessage(deliveryAddress)
|
||||
d.AddCommandLog(command.ID, "created",
|
||||
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
|
||||
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
|
||||
username)
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetApprovedCommands() ([]models.Command, error) {
|
||||
var commands []models.Command
|
||||
err := d.GDB.Where("status = ?", "approved").Order("created_at DESC").Find(&commands).Error
|
||||
return commands, err
|
||||
}
|
||||
|
||||
func (d *Database) GetAllCommands(status, username string) ([]map[string]any, error) {
|
||||
if username != "" {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if status != "" {
|
||||
if err := validateCommandStatus(status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
ProposedAddress *string `gorm:"column:proposed_address"`
|
||||
AddressProposalStatus string `gorm:"column:address_proposal_status"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
CancelReason string `gorm:"column:cancel_reason"`
|
||||
}
|
||||
|
||||
gdb := d.GDB.Table("commandes c").
|
||||
Select(`c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.livreur_assign, c.created_at, c.updated_at,
|
||||
c.proposed_address, c.address_proposal_status,
|
||||
c.client_order_id AS client_order_number,
|
||||
COALESCE(c.referral_used, 0) AS referral_used,
|
||||
COALESCE(c.cancel_reason, '') AS cancel_reason`)
|
||||
|
||||
if status == "" {
|
||||
gdb = gdb.Where("c.status IN ?", []string{"pending", "assigned", "en_route", "arrived", "livre"})
|
||||
} else {
|
||||
gdb = gdb.Where("c.status = ?", status)
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
gdb = gdb.Where("c.username = ?", username)
|
||||
}
|
||||
|
||||
if err := gdb.Order("c.created_at DESC").Limit(1000).Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||||
}
|
||||
|
||||
commands := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
command := map[string]any{
|
||||
"id": row.ID,
|
||||
"username": row.Username,
|
||||
"status": row.Status,
|
||||
"adresse": sanitizeString(row.Adresse),
|
||||
"total_prix": row.TotalPrix,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
"address_proposal_status": row.AddressProposalStatus,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"cancel_reason": row.CancelReason,
|
||||
}
|
||||
|
||||
if row.LivreurAssign != nil {
|
||||
command["livreur_assign"] = *row.LivreurAssign
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
if row.ProposedAddress != nil {
|
||||
command["proposed_address"] = *row.ProposedAddress
|
||||
} else {
|
||||
command["proposed_address"] = nil
|
||||
}
|
||||
|
||||
commands = append(commands, command)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
|
||||
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
ProposedAddress *string `gorm:"column:proposed_address"`
|
||||
AddressProposalStatus string `gorm:"column:address_proposal_status"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
CancelReason string `gorm:"column:cancel_reason"`
|
||||
DestLatitude float64 `gorm:"column:dest_latitude"`
|
||||
DestLongitude float64 `gorm:"column:dest_longitude"`
|
||||
}
|
||||
|
||||
if err := d.GDB.Table("commandes c").
|
||||
Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign,
|
||||
c.created_at, c.updated_at, c.proposed_address, c.address_proposal_status,
|
||||
c.referral_used, c.client_order_id AS client_order_number,
|
||||
COALESCE(c.cancel_reason, '') AS cancel_reason,
|
||||
COALESCE(c.dest_latitude, 0) AS dest_latitude,
|
||||
COALESCE(c.dest_longitude, 0) AS dest_longitude`).
|
||||
Where("c.id = ?", id).
|
||||
First(&row).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
command := map[string]any{
|
||||
"id": row.ID,
|
||||
"username": row.Username,
|
||||
"status": row.Status,
|
||||
"adresse": row.Adresse,
|
||||
"total_prix": row.TotalPrix,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
"address_proposal_status": row.AddressProposalStatus,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
"cancel_reason": row.CancelReason,
|
||||
"dest_latitude": row.DestLatitude,
|
||||
"dest_longitude": row.DestLongitude,
|
||||
}
|
||||
|
||||
if row.LivreurAssign != nil {
|
||||
command["livreur_assign"] = *row.LivreurAssign
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
if row.ProposedAddress != nil {
|
||||
command["proposed_address"] = *row.ProposedAddress
|
||||
} else {
|
||||
command["proposed_address"] = nil
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
const lastDeliveryCoordsCacheTTL = 5 * time.Minute
|
||||
|
||||
func lastDeliveryCoordsCacheKey(livreurUsername string) string {
|
||||
return fmt.Sprintf("livreur:last_delivery_coords:%s", livreurUsername)
|
||||
}
|
||||
|
||||
// GetLastDeliveryCoords retourne les coordonnées GPS de la dernière livraison terminée d'un livreur.
|
||||
// Utilisé comme fallback quand le GPS temps réel est indisponible. Mis en cache quelques minutes
|
||||
// car appelé à chaque calcul d'ETA et la dernière livraison ne change pas souvent.
|
||||
func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) {
|
||||
cacheKey := lastDeliveryCoordsCacheKey(livreurUsername)
|
||||
|
||||
if cached, err := Redis.Get(RedisCtx, cacheKey).Result(); err == nil {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal([]byte(cached), &coords); jsonErr == nil {
|
||||
return coords.Lat, coords.Lon, nil
|
||||
}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
DestLatitude float64 `gorm:"column:dest_latitude"`
|
||||
DestLongitude float64 `gorm:"column:dest_longitude"`
|
||||
}
|
||||
|
||||
if err := d.GDB.Table("commandes").
|
||||
Select("dest_latitude, dest_longitude").
|
||||
Where("livreur_assign = ? AND status IN (?, ?, ?) AND dest_latitude IS NOT NULL AND dest_latitude != 0 AND dest_longitude IS NOT NULL AND dest_longitude != 0",
|
||||
livreurUsername, "livre", "delivered", "approved").
|
||||
Order("updated_at DESC").
|
||||
Limit(1).
|
||||
Scan(&result).Error; err != nil {
|
||||
return 0, 0, fmt.Errorf("aucune livraison précédente pour %s: %w", livreurUsername, err)
|
||||
}
|
||||
|
||||
if result.DestLatitude == 0 || result.DestLongitude == 0 {
|
||||
return 0, 0, fmt.Errorf("coordonnées introuvables pour dernière livraison de %s", livreurUsername)
|
||||
}
|
||||
|
||||
if coordsJSON, err := json.Marshal(map[string]float64{"lat": result.DestLatitude, "lon": result.DestLongitude}); err == nil {
|
||||
Redis.Set(RedisCtx, cacheKey, coordsJSON, lastDeliveryCoordsCacheTTL)
|
||||
}
|
||||
|
||||
return result.DestLatitude, result.DestLongitude, nil
|
||||
}
|
||||
|
||||
// GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global.
|
||||
// Retourne commandID en fallback si introuvable.
|
||||
func (d *Database) GetClientOrderID(commandID int) int {
|
||||
var result struct {
|
||||
ClientOrderID int `gorm:"column:client_order_id"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.Command{}).Select("client_order_id").Where("id = ?", commandID).First(&result).Error; err != nil || result.ClientOrderID == 0 {
|
||||
return commandID
|
||||
}
|
||||
return result.ClientOrderID
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
|
||||
if len(deliveryAddress) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||||
}
|
||||
if strings.TrimSpace(deliveryAddress) == "" {
|
||||
return fmt.Errorf("adresse vide non autorisée")
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||||
"adresse": deliveryAddress,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("✅ Adresse commande %d mise à jour", commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de SA
|
||||
// PROPRE commande, tant qu'elle n'est pas encore prise en charge par un
|
||||
// livreur (statut "en_route") ni terminée. La vérification d'appartenance et
|
||||
// de statut se fait dans la clause WHERE, atomiquement : impossible de
|
||||
// modifier la commande d'un autre client ou une commande déjà en route.
|
||||
func (d *Database) UpdateOwnCommandAddress(commandID int, clientUsername, deliveryAddress string) error {
|
||||
if err := validateAddress(deliveryAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE commandes
|
||||
SET adresse = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND username = ? AND status IN ('pending', 'assigned')`,
|
||||
deliveryAddress, commandID, clientUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande introuvable, non modifiable (déjà en livraison ou terminée), ou n'appartenant pas à ce client")
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "address_updated",
|
||||
fmt.Sprintf("Adresse corrigée par le client %s", clientUsername),
|
||||
clientUsername)
|
||||
|
||||
log.Printf("✅ [UPD_OWN_ADDR] Adresse commande %d corrigée par %s", commandID, clientUsername)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
|
||||
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
|
||||
if err := validateAddress(proposedAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||||
"proposed_address": proposedAddress,
|
||||
"address_proposal_status": "pending",
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur proposition adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "address_proposed",
|
||||
fmt.Sprintf("Nouvelle adresse proposée par %s: %s", proposedBy, proposedAddress),
|
||||
proposedBy)
|
||||
|
||||
log.Printf("✅ Adresse proposée pour commande %d par %s", commandID, proposedBy)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespondToAddressProposal accepte ou refuse la proposition d'adresse
|
||||
func (d *Database) RespondToAddressProposal(commandID int, clientUsername string, accepted bool) error {
|
||||
var query string
|
||||
if accepted {
|
||||
query = `UPDATE commandes
|
||||
SET adresse = proposed_address, proposed_address = NULL,
|
||||
address_proposal_status = 'accepted', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
|
||||
} else {
|
||||
query = `UPDATE commandes
|
||||
SET proposed_address = NULL, address_proposal_status = 'rejected',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(query, commandID, clientUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur réponse proposition adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("aucune proposition en attente pour cette commande")
|
||||
}
|
||||
|
||||
action := "refusée"
|
||||
if accepted {
|
||||
action = "acceptée"
|
||||
}
|
||||
d.AddCommandLog(commandID, "address_proposal_"+action,
|
||||
fmt.Sprintf("Proposition d'adresse %s par le client %s", action, clientUsername),
|
||||
clientUsername)
|
||||
|
||||
log.Printf("✅ Proposition adresse %s pour commande %d", action, commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCommandStatus met à jour le statut d'une commande
|
||||
func (d *Database) UpdateCommandStatus(commandID int, status string) error {
|
||||
if err := validateCommandStatus(status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AddCommandLog(commandID int, status, message, author string) error {
|
||||
sanitizedMessage := sanitizeLogMessage(message)
|
||||
sanitizedAuthor := sanitizeLogMessage(author)
|
||||
|
||||
if err := d.GDB.Create(&models.CommandLog{
|
||||
CommandID: commandID,
|
||||
Status: status,
|
||||
Message: sanitizedMessage,
|
||||
Author: sanitizedAuthor,
|
||||
}).Error; err != nil {
|
||||
log.Printf("⚠️ Avertissement: impossible d'ajouter le log (table command_logs peut-être manquante): %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommandLogs récupère tous les logs d'une commande
|
||||
func (d *Database) GetCommandLogs(commandID int) ([]map[string]any, error) {
|
||||
var rows []models.CommandLog
|
||||
|
||||
if err := d.GDB.Where("command_id = ?", commandID).Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||
log.Printf("⚠️ Avertissement: impossible de récupérer les logs: %v", err)
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
|
||||
logs := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
logs = append(logs, map[string]any{
|
||||
"id": row.ID,
|
||||
"command_id": row.CommandID,
|
||||
"status": row.Status,
|
||||
"message": row.Message,
|
||||
"author": row.Author,
|
||||
"created_at": row.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func sanitizeLogMessage(message string) string {
|
||||
sanitized := strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, message)
|
||||
|
||||
if len(sanitized) > 1000 {
|
||||
sanitized = sanitized[:1000]
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (int, error) {
|
||||
var totalPoints int
|
||||
var cmdUsernameOut string
|
||||
var livreurAssignOut string
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmd struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign, total_prix
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||||
log.Printf("❌ [ValidateAtomic] Erreur SELECT: %v", err)
|
||||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||||
}
|
||||
if cmd.Username == "" {
|
||||
log.Printf("❌ [ValidateAtomic] Commande %d non trouvée", commandID)
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
|
||||
cmd.Status, cmd.Username, cmd.LivreurAssign)
|
||||
|
||||
validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
|
||||
if !slices.Contains(validStatuses, cmd.Status) {
|
||||
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
|
||||
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
|
||||
}
|
||||
|
||||
if cmd.Status == "approved" {
|
||||
log.Printf("⚠️ [ValidateAtomic] Commande %d déjà approuvée", commandID)
|
||||
return fmt.Errorf("commande déjà approuvée")
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = ?`, commandID, cmd.Status)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ValidateAtomic] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [ValidateAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
|
||||
return fmt.Errorf("commande déjà modifiée par une autre requête")
|
||||
}
|
||||
|
||||
if cmd.Username != "" {
|
||||
log.Printf("🔍 [ValidateAtomic] Calcul points pour client: %s", cmd.Username)
|
||||
|
||||
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ValidateAtomic] Erreur calcul/ajout points: %v", err)
|
||||
return fmt.Errorf("erreur attribution points: %w", err)
|
||||
}
|
||||
totalPoints = points
|
||||
|
||||
log.Printf("✅ [ValidateAtomic] %d points attribués à %s", totalPoints, cmd.Username)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, cmd.Username).Error; err != nil {
|
||||
log.Printf("⚠️ [ValidateAtomic] Erreur incrémentation compteur: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [ValidateAtomic] Compteur commandes incrémenté pour %s", cmd.Username)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Livraison validée par admin %s - %d points attribués", adminUsername, totalPoints),
|
||||
adminUsername).Error; err != nil {
|
||||
log.Printf("⚠️ [ValidateAtomic] Erreur ajout log: %v", err)
|
||||
}
|
||||
|
||||
cmdUsernameOut = cmd.Username
|
||||
livreurAssignOut = cmd.LivreurAssign
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [ValidateAtomic] SUCCÈS - Commande %d validée, %d points attribués",
|
||||
commandID, totalPoints)
|
||||
|
||||
if livreurAssignOut != "" {
|
||||
log.Printf("📦 [ValidateAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
|
||||
go func() {
|
||||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||||
log.Printf("⚠️ [ValidateAtomic] Erreur optimisation queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
commandCacheKey := fmt.Sprintf("command:%d", commandID)
|
||||
Redis.Del(RedisCtx, commandCacheKey)
|
||||
|
||||
if cmdUsernameOut != "" {
|
||||
clientCacheKey := fmt.Sprintf("client:%s", cmdUsernameOut)
|
||||
Redis.Del(RedisCtx, clientCacheKey)
|
||||
|
||||
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", cmdUsernameOut)
|
||||
Redis.Del(RedisCtx, clientCommandsCacheKey)
|
||||
}
|
||||
|
||||
log.Printf("✅ [ValidateAtomic] Caches invalidés pour cmd %d", commandID)
|
||||
}()
|
||||
|
||||
return totalPoints, nil
|
||||
}
|
||||
|
||||
// ApproveDeliveryAtomic - Version atomique pour approbation client
|
||||
func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, string, error) {
|
||||
log.Printf("🔒 [ApproveAtomic] START - cmd=%d, client=%s", commandID, username)
|
||||
|
||||
var totalPoints int
|
||||
var pointCategory string
|
||||
var livreurAssignOut string
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmd struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||||
log.Printf("❌ [ApproveAtomic] Erreur SELECT: %v", err)
|
||||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||||
}
|
||||
if cmd.Username == "" {
|
||||
log.Printf("❌ [ApproveAtomic] Commande %d non trouvée", commandID)
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [ApproveAtomic] Commande trouvée - status=%s, owner=%s", cmd.Status, cmd.Username)
|
||||
|
||||
if cmd.Username != username {
|
||||
log.Printf("❌ [ApproveAtomic] Commande n'appartient pas à %s (propriétaire: %s)",
|
||||
username, cmd.Username)
|
||||
return fmt.Errorf("cette commande ne vous appartient pas")
|
||||
}
|
||||
|
||||
if cmd.Status == "approved" {
|
||||
log.Printf("ℹ️ [ApproveAtomic] Commande %d déjà approuvée — réponse idempotente", commandID)
|
||||
return errAlreadyApproved
|
||||
}
|
||||
|
||||
if cmd.Status != "livre" {
|
||||
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status)
|
||||
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ApproveAtomic] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [ApproveAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
|
||||
return fmt.Errorf("commande déjà approuvée ou modifiée")
|
||||
}
|
||||
|
||||
log.Printf("✅ [ApproveAtomic] Statut mis à jour: livre → approved")
|
||||
|
||||
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ApproveAtomic] Erreur calcul points: %v", err)
|
||||
return fmt.Errorf("erreur attribution points: %w", err)
|
||||
}
|
||||
totalPoints = pts
|
||||
pointCategory = cat
|
||||
|
||||
log.Printf("✅ [ApproveAtomic] %d points [%s] attribués à %s", totalPoints, pointCategory, username)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomic] Erreur incrémentation compteur: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Livraison confirmée par le client %s - %d points [%s] attribués", username, totalPoints, pointCategory),
|
||||
username).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomic] Erreur ajout log: %v", err)
|
||||
}
|
||||
|
||||
livreurAssignOut = cmd.LivreurAssign
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyApproved) {
|
||||
return 0, "", nil
|
||||
}
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points [%s] attribués",
|
||||
commandID, totalPoints, pointCategory)
|
||||
|
||||
if livreurAssignOut != "" {
|
||||
log.Printf("📦 [ApproveAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
|
||||
go func() {
|
||||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomic] Erreur optimisation queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
commandCacheKey := fmt.Sprintf("command:%d", commandID)
|
||||
Redis.Del(RedisCtx, commandCacheKey)
|
||||
|
||||
clientCacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, clientCacheKey)
|
||||
|
||||
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", username)
|
||||
Redis.Del(RedisCtx, clientCommandsCacheKey)
|
||||
|
||||
log.Printf("✅ [ApproveAtomic] Caches invalidés")
|
||||
}()
|
||||
|
||||
return totalPoints, pointCategory, nil
|
||||
}
|
||||
|
||||
// ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client
|
||||
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, string, error) {
|
||||
log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername)
|
||||
|
||||
var totalPoints int
|
||||
var pointCategory string
|
||||
var clientUsernameOut string
|
||||
var livreurAssignOut string
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmd struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||||
}
|
||||
if cmd.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
// Historiquement restreint à "livre" seul (cf. commentaire de
|
||||
// TestApproveDeliveryAtomicByStaff dans les tests) — élargi après un
|
||||
// incident réel où une vérification GPS en amont (coordonnées de
|
||||
// destination périmées après un changement d'adresse, cf.
|
||||
// updateCommandDestinationCoords) a bloqué la transition du livreur
|
||||
// vers "livre" : la commande restait alors coincée, sans qu'admin ni
|
||||
// cabine ne puissent confirmer la réception. On accepte désormais tout
|
||||
// statut non terminal ("arrived" inclus), à l'image de
|
||||
// ValidateDeliveryAtomic (qui accepte déjà pending/assigned/en_route),
|
||||
// pour que le staff garde toujours un moyen de débloquer une commande
|
||||
// légitime indépendamment d'un blocage en amont côté livreur.
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre"}
|
||||
if !slices.Contains(validStatuses, cmd.Status) {
|
||||
return fmt.Errorf("statut invalide pour confirmation de réception: %s", cmd.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = ?`, commandID, cmd.Status)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande déjà approuvée ou modifiée")
|
||||
}
|
||||
|
||||
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur attribution points: %w", err)
|
||||
}
|
||||
totalPoints = pts
|
||||
pointCategory = cat
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, cmd.Username).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur incrémentation compteur: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Réception confirmée par %s au nom du client %s - %d points attribués", staffUsername, cmd.Username, totalPoints),
|
||||
staffUsername).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur log: %v", err)
|
||||
}
|
||||
|
||||
clientUsernameOut = cmd.Username
|
||||
livreurAssignOut = cmd.LivreurAssign
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s",
|
||||
commandID, staffUsername, totalPoints, clientUsernameOut)
|
||||
|
||||
if livreurAssignOut != "" {
|
||||
go func() {
|
||||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("command:%d", commandID))
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("client:%s", clientUsernameOut))
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsernameOut))
|
||||
}()
|
||||
|
||||
return totalPoints, pointCategory, clientUsernameOut, nil
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandCancelReason(commandID int, reason string) error {
|
||||
if len(reason) > 500 {
|
||||
reason = reason[:500]
|
||||
}
|
||||
return d.GDB.Exec(
|
||||
`UPDATE commandes SET cancel_reason = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
reason, commandID,
|
||||
).Error
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) AddContact(contact *models.Contact) error {
|
||||
err := d.GDB.Create(contact).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetContact(id uint) (*models.Contact, error) {
|
||||
var contact models.Contact
|
||||
if err := d.GDB.First(&contact, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contact, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateContact(contact *models.Contact) error {
|
||||
err := d.GDB.Save(contact).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) DeleteContact(id uint) error {
|
||||
err := d.GDB.Delete(&models.Contact{}, id).Error
|
||||
return err
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetAvailableDeliveryPersons récupère tous les livreurs disponibles
|
||||
func (d *Database) GetAvailableDeliveryPersons() ([]map[string]any, error) {
|
||||
var livreurs []map[string]any
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, total, livraison
|
||||
FROM users
|
||||
WHERE role = 'livreur'
|
||||
ORDER BY username`).Scan(&livreurs).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
|
||||
}
|
||||
return livreurs, nil
|
||||
}
|
||||
|
||||
// Admin appelle cette fonction pour assigner un livreur
|
||||
func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) error {
|
||||
log.Printf("📦 [AssignDeliveryPerson] START - commandID=%d, livreur=%s", commandID, livreurUsername)
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var roleResult struct {
|
||||
Role string `gorm:"column:role"`
|
||||
}
|
||||
err := tx.Raw(`SELECT role FROM users WHERE username = ? FOR UPDATE`, livreurUsername).Scan(&roleResult).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur vérification livreur: %v", err)
|
||||
return fmt.Errorf("erreur lors de la vérification du livreur: %w", err)
|
||||
}
|
||||
if roleResult.Role == "" {
|
||||
log.Printf("❌ Livreur '%s' non trouvé", livreurUsername)
|
||||
return fmt.Errorf("livreur non trouvé")
|
||||
}
|
||||
if roleResult.Role != "livreur" {
|
||||
log.Printf("❌ L'utilisateur '%s' n'est pas un livreur (role=%s)", livreurUsername, roleResult.Role)
|
||||
return fmt.Errorf("l'utilisateur n'est pas un livreur")
|
||||
}
|
||||
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err = tx.Raw(`SELECT status, livreur_assign FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur vérification commande: %v", err)
|
||||
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
|
||||
}
|
||||
if cmdResult.Status == "" {
|
||||
log.Printf("❌ Commande %d non trouvée", commandID)
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
validStatusesForAssignment := []string{"pending", "assigned"}
|
||||
if !slices.Contains(validStatusesForAssignment, cmdResult.Status) {
|
||||
log.Printf("❌ Statut invalide pour assignation: %s", cmdResult.Status)
|
||||
return fmt.Errorf("commande en statut '%s', impossible d'assigner un livreur", cmdResult.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes
|
||||
SET livreur_assign = ?,
|
||||
status = 'assigned',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
AND status IN ('pending', 'assigned')`, livreurUsername, commandID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur lors de l'assignation du livreur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ Impossible d'assigner: conditions non remplies")
|
||||
return fmt.Errorf("impossible d'assigner la commande (déjà assignée ou statut changé)")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "assigned",
|
||||
fmt.Sprintf("Livraison assignée au livreur %s", livreurUsername),
|
||||
"admin",
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur ajout log: %v", err)
|
||||
// Non bloquant
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) {
|
||||
query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?`
|
||||
|
||||
args := []any{livreurUsername}
|
||||
|
||||
if status != "" {
|
||||
query += " AND status = ?"
|
||||
args = append(args, status)
|
||||
} else {
|
||||
// Ne retourner que les commandes actives — exclure l'historique terminé
|
||||
// pour éviter le N+1 sur 66+ commandes qui fait timeout le mobile
|
||||
query += " AND status IN ('assigned', 'en_route', 'arrived', 'livre')"
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT username, status, livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
|
||||
}
|
||||
if cmdResult.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
if cmdResult.Username != clientUsername {
|
||||
return fmt.Errorf("cette commande ne vous appartient pas")
|
||||
}
|
||||
if cmdResult.Status != "livre" {
|
||||
return fmt.Errorf("cette commande n'est pas encore livrée (statut actuel: %s)", cmdResult.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes
|
||||
SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, clientUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'approbation de la livraison: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("impossible d'approuver: statut changé ou commande introuvable")
|
||||
}
|
||||
|
||||
if cmdResult.LivreurAssign != nil && *cmdResult.LivreurAssign != "" {
|
||||
res := tx.Exec(`
|
||||
UPDATE users
|
||||
SET livraison = livraison + 1,
|
||||
total = total + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ? AND role = 'livreur'`, *cmdResult.LivreurAssign)
|
||||
if res.Error != nil {
|
||||
log.Printf("⚠️ Erreur incrémentation livreur: %v", res.Error)
|
||||
} else if res.RowsAffected > 0 {
|
||||
log.Printf(" ✅ Compteur livreur incrémenté: %s", *cmdResult.LivreurAssign)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Livraison approuvée par le client %s", clientUsername),
|
||||
clientUsername,
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur ajout log: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (d *Database) GetDeliveryIssues(status string) ([]models.DeliveryIssue, error) {
|
||||
q := d.GDB.Order("created_at DESC")
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var issues []models.DeliveryIssue
|
||||
if err := q.Find(&issues).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération problèmes: %w", err)
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateDeliveryIssue(commandID int, issueType, description, reportedBy string) (*models.DeliveryIssue, error) {
|
||||
issue := models.DeliveryIssue{
|
||||
CommandID: commandID,
|
||||
IssueType: issueType,
|
||||
Description: description,
|
||||
Status: "open",
|
||||
ReportedBy: reportedBy,
|
||||
}
|
||||
if err := d.GDB.Create(&issue).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur création problème: %w", err)
|
||||
}
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateDeliveryIssue(issueID int, status, resolution, resolvedBy string) error {
|
||||
result := d.GDB.Model(&models.DeliveryIssue{}).Where("id = ?", issueID).
|
||||
Updates(map[string]any{"status": status, "resolution": resolution, "resolved_by": resolvedBy})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour problème: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("problème non trouvé")
|
||||
}
|
||||
log.Printf("✅ Problème %d mis à jour: status=%s", issueID, status)
|
||||
return nil
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
// ============================================
|
||||
// db/delivery_management_db.go
|
||||
// FONCTIONS DB POUR LA GESTION COMPLÈTE DES LIVREURS
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var allowedStatuses = map[string]bool{
|
||||
"available": true,
|
||||
"offline": true,
|
||||
"busy": true,
|
||||
}
|
||||
|
||||
// CountDeliveriesByStatus compte les livraisons d'un livreur par statut
|
||||
func (d *Database) CountDeliveriesByStatus(livreurUsername string, statuses string) (int, error) {
|
||||
if statuses == "" {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COUNT(*) as count FROM commandes WHERE livreur_assign = ?`, livreurUsername).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
|
||||
}
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
cleanStatuses, err := ValidateStatuses(statuses)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Validation échouée: %v", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err = d.GDB.Raw(
|
||||
`SELECT COUNT(*) as count FROM commandes WHERE livreur_assign = ? AND status IN ?`,
|
||||
livreurUsername, cleanStatuses,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [CountDeliveries] %d livraisons pour %s avec statuts %v",
|
||||
result.Count, livreurUsername, cleanStatuses)
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
func ValidateStatuses(statuses string) ([]string, error) {
|
||||
if statuses == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
statusList := strings.Split(statuses, ",")
|
||||
|
||||
validStatusMap := map[string]bool{
|
||||
"pending": true,
|
||||
"assigned": true,
|
||||
"en_route": true,
|
||||
"arrived": true,
|
||||
"livre": true,
|
||||
"approved": true,
|
||||
"cancelled": true,
|
||||
}
|
||||
|
||||
var cleanStatuses []string
|
||||
var invalidStatuses []string
|
||||
|
||||
for _, status := range statusList {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
continue
|
||||
}
|
||||
if validStatusMap[status] {
|
||||
cleanStatuses = append(cleanStatuses, status)
|
||||
} else {
|
||||
invalidStatuses = append(invalidStatuses, status)
|
||||
}
|
||||
}
|
||||
|
||||
if len(invalidStatuses) > 0 {
|
||||
log.Printf("⚠️ [ValidateStatuses] Statuts invalides ignorés: %v", invalidStatuses)
|
||||
}
|
||||
|
||||
if len(cleanStatuses) == 0 {
|
||||
return nil, fmt.Errorf("aucun statut valide trouvé dans: %s", statuses)
|
||||
}
|
||||
|
||||
return cleanStatuses, nil
|
||||
}
|
||||
|
||||
// GetLastDeliveryDate récupère la date de la dernière livraison d'un livreur
|
||||
func (d *Database) GetLastDeliveryDate(livreurUsername string) (*time.Time, error) {
|
||||
var result struct {
|
||||
LastDate *time.Time `gorm:"column:last_date"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT MAX(updated_at) as last_date
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ? AND status = 'approved'`, livreurUsername).Scan(&result).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération dernière livraison: %w", err)
|
||||
}
|
||||
return result.LastDate, nil
|
||||
}
|
||||
|
||||
// GetCurrentCommand récupère l'ID de la commande en cours d'un livreur
|
||||
func (d *Database) GetCurrentCommand(livreurUsername string) (int, error) {
|
||||
currentKey := fmt.Sprintf("delivery:current:%s", livreurUsername)
|
||||
currentIDStr, err := Redis.Get(RedisCtx, currentKey).Result()
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var currentID int
|
||||
_, err = fmt.Sscanf(currentIDStr, "%d", ¤tID)
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonHistory récupère l'historique paginé des livraisons d'un livreur
|
||||
func (d *Database) GetDeliveryPersonHistory(livreurUsername string, limit, offset int) ([]map[string]any, error) {
|
||||
var history []map[string]any
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
c.id as command_id,
|
||||
c.username as client,
|
||||
c.status,
|
||||
c.adresse,
|
||||
c.total_prix::float8 as total_prix,
|
||||
c.created_at as assigned_at,
|
||||
c.updated_at as completed_at
|
||||
FROM commandes c
|
||||
WHERE c.livreur_assign = ?
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ? OFFSET ?`, livreurUsername, limit, offset).Scan(&history).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération historique: %w", err)
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonStatus récupère le statut d'un livreur
|
||||
func (d *Database) GetDeliveryPersonStatus(livreurUsername string) (string, error) {
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
|
||||
status, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
return "offline", nil
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// UpdateDeliveryPersonStatus met à jour le statut d'un livreur
|
||||
func (d *Database) UpdateDeliveryPersonStatus(livreurUsername string, status string) error {
|
||||
if !allowedStatuses[status] {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
log.Printf("🔄 [UpdateStatus] Mise à jour: %s → %s", livreurUsername, status)
|
||||
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
|
||||
if err := Redis.Set(RedisCtx, statusKey, status, 0).Err(); err != nil {
|
||||
log.Printf("❌ [UpdateStatus] Erreur Redis: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [UpdateStatus] Statut mis à jour pour %s: %s", livreurUsername, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeliverymanQueueSize récupère la taille de la queue d'un livreur
|
||||
func (d *Database) GetDeliverymanQueueSize(livreurUsername string) (int, error) {
|
||||
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
|
||||
size, err := Redis.LLen(RedisCtx, queueKey).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération taille queue: %w", err)
|
||||
}
|
||||
return int(size), nil
|
||||
}
|
||||
|
||||
// GetDeliverymanQueue récupère la queue complète d'un livreur
|
||||
func (d *Database) GetDeliverymanQueue(livreurUsername string) ([]int, error) {
|
||||
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
|
||||
commands, err := Redis.LRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération queue: %w", err)
|
||||
}
|
||||
|
||||
var queue []int
|
||||
for _, cmdStr := range commands {
|
||||
var cmdID int
|
||||
fmt.Sscanf(cmdStr, "%d", &cmdID)
|
||||
queue = append(queue, cmdID)
|
||||
}
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
// UpdateCommandLivreur met à jour le livreur assigné à une commande
|
||||
func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE commandes
|
||||
SET livreur_assign = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, livreurUsername, commandID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour livreur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (d *Database) GetCommandCategories(commandID int) ([]string, error) {
|
||||
var categories []string
|
||||
err := d.GDB.Raw(`
|
||||
SELECT DISTINCT p.category
|
||||
FROM command_items ci
|
||||
JOIN products p ON p.id = ci.product_id
|
||||
WHERE ci.command_id = ? AND p.category IS NOT NULL AND p.category != ''`,
|
||||
commandID,
|
||||
).Scan(&categories).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lecture catégories commande %d: %w", commandID, err)
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetEligibleDeliverymenForCommand(commandID int) ([]string, error) {
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [DELIVERY_MODE] Erreur lecture settings: %v — fallback single", err)
|
||||
}
|
||||
|
||||
allActive := func() ([]string, error) {
|
||||
livreurs, err := d.GetAllActiveDeliveryPersons()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, len(livreurs))
|
||||
for i, l := range livreurs {
|
||||
names[i] = l.Username
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
if settings.DeliveryMode.Mode != "category_based" || len(settings.DeliveryMode.CategoryRoutes) == 0 {
|
||||
return allActive()
|
||||
}
|
||||
|
||||
categories, err := d.GetCommandCategories(commandID)
|
||||
if err != nil || len(categories) == 0 {
|
||||
log.Printf("⚠️ [DELIVERY_MODE] Cmd %d: catégories non trouvées — fallback single", commandID)
|
||||
return allActive()
|
||||
}
|
||||
|
||||
catSet := make(map[string]bool, len(categories))
|
||||
for _, c := range categories {
|
||||
catSet[c] = true
|
||||
}
|
||||
|
||||
eligible := make(map[string]bool)
|
||||
for _, route := range settings.DeliveryMode.CategoryRoutes {
|
||||
for _, routeCat := range route.Categories {
|
||||
if catSet[routeCat] {
|
||||
eligible[route.DeliverymanUsername] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(eligible) == 0 {
|
||||
log.Printf("⚠️ [DELIVERY_MODE] Cmd %d: aucun livreur pour catégories %v — fallback single", commandID, categories)
|
||||
return allActive()
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(eligible))
|
||||
for u := range eligible {
|
||||
result = append(result, u)
|
||||
}
|
||||
|
||||
log.Printf("🎯 [DELIVERY_MODE] Cmd %d: livreurs éligibles %v (catégories: %v)", commandID, result, categories)
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// ============================================
|
||||
// db/gps_links.go
|
||||
// Génération de liens GPS vers différentes plateformes
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MapLinks contient les liens vers différentes plateformes de cartographie
|
||||
type MapLinks struct {
|
||||
GoogleMaps string `json:"google_maps"`
|
||||
GoogleMapsApp string `json:"google_maps_app"`
|
||||
Waze string `json:"waze"`
|
||||
WazeApp string `json:"waze_app"`
|
||||
AppleMaps string `json:"apple_maps"`
|
||||
OpenStreetMap string `json:"openstreetmap"`
|
||||
BingMaps string `json:"bing_maps"`
|
||||
HereMaps string `json:"here_maps"`
|
||||
}
|
||||
|
||||
func wazeAppLink(lat, lon float64) string {
|
||||
return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon)
|
||||
}
|
||||
|
||||
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
|
||||
return MapLinks{
|
||||
WazeApp: wazeAppLink(lat, lon),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
|
||||
return wazeAppLink(toLat, toLon)
|
||||
}
|
||||
|
||||
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
|
||||
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("position livreur non disponible: %w", err)
|
||||
}
|
||||
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commande non trouvée: %w", err)
|
||||
}
|
||||
|
||||
var destLat, destLon float64
|
||||
if v, ok := command["dest_latitude"].(float64); ok {
|
||||
destLat = v
|
||||
}
|
||||
if v, ok := command["dest_longitude"].(float64); ok {
|
||||
destLon = v
|
||||
}
|
||||
|
||||
if destLat == 0 && destLon == 0 {
|
||||
return nil, fmt.Errorf("coordonnées destination invalides")
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"waze_app": wazeAppLink(destLat, destLon),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// ============================================
|
||||
// db/commands_history.go
|
||||
// ============================================
|
||||
// Fonctions pour l'historique des commandes terminées
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
|
||||
func (d *Database) GetCompletedCommandsByUsername(username string) ([]map[string]any, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
client_order_id AS client_order_number,
|
||||
username,
|
||||
status,
|
||||
adresse,
|
||||
total_prix::float8 as total_prix,
|
||||
livreur_assign,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM commandes
|
||||
WHERE username = ? AND status = 'approved'
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, username).Scan(&commands).Error; err != nil {
|
||||
log.Printf("❌ [GetCompletedCommands] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes terminées: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
@@ -1,626 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// STRUCTURES
|
||||
// ============================================
|
||||
|
||||
// Database encapsule la connexion à la base de données
|
||||
type Database struct {
|
||||
*sql.DB
|
||||
GDB *gorm.DB
|
||||
}
|
||||
|
||||
// DB est l'instance globale de la base de données
|
||||
var DB *Database
|
||||
|
||||
// ============================================
|
||||
// INITIALISATION DE LA BASE DE DONNÉES
|
||||
// ============================================
|
||||
|
||||
// InitDB initialise la connexion à PostgreSQL et crée les tables
|
||||
func InitDB() *Database {
|
||||
// Récupérer les paramètres de connexion
|
||||
host := getEnv("DB_HOST", "localhost")
|
||||
port := getEnv("DB_PORT", "5432")
|
||||
user := getEnv("DB_USER", "postgres")
|
||||
password := getEnv("DB_PASSWORD", "postgres")
|
||||
dbname := getEnv("DB_NAME", "gestion_db")
|
||||
sslmode := getEnv("DB_SSLMODE", "disable")
|
||||
|
||||
// Construire la chaîne de connexion
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||
host, port, user, password, dbname, sslmode)
|
||||
|
||||
// Ouvrir la connexion
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur lors de l'ouverture de la base de données: %v", err)
|
||||
}
|
||||
|
||||
// Configuration du pool de connexions
|
||||
db.SetMaxOpenConns(50)
|
||||
db.SetMaxIdleConns(10)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
// Tester la connexion
|
||||
if err = db.Ping(); err != nil {
|
||||
log.Fatalf("❌ Erreur de connexion à la base de données: %v", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Connexion à PostgreSQL établie avec succès")
|
||||
|
||||
// Initialiser GORM en réutilisant la connexion sql.DB existante
|
||||
gormDB, err := gorm.Open(postgres.New(postgres.Config{
|
||||
Conn: db,
|
||||
}), &gorm.Config{
|
||||
SkipDefaultTransaction: true,
|
||||
PrepareStmt: true,
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur initialisation GORM: %v", err)
|
||||
}
|
||||
|
||||
// Créer l'instance Database
|
||||
database := &Database{db, gormDB}
|
||||
|
||||
// Assigner à la variable globale
|
||||
DB = database
|
||||
|
||||
// Créer les tables
|
||||
if err = database.createTables(); err != nil {
|
||||
log.Fatalf("❌ Erreur lors de la création des tables: %v", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Tables créées avec succès")
|
||||
|
||||
// Migration: ajouter colonne must_change_password si elle n'existe pas (DEFAULT FALSE pour les clients existants)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration must_change_password: %v", err)
|
||||
}
|
||||
|
||||
// Migration: proposition de modification d'adresse par admin/cabine
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS proposed_address TEXT`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration proposed_address: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS address_proposal_status VARCHAR(20) NOT NULL DEFAULT 'none'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration address_proposal_status: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne unit pour l'unité de mesure des produits (kg, g, bag, l, cl, pcs, u)
|
||||
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS unit VARCHAR(10) NOT NULL DEFAULT 'u'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration unit products: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.quantity INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires (ex: 0.5g)
|
||||
if _, err = database.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'baskets' AND column_name = 'quantity'
|
||||
AND data_type = 'integer'
|
||||
) THEN
|
||||
ALTER TABLE baskets ALTER COLUMN quantity TYPE NUMERIC(10,3) USING quantity::NUMERIC(10,3);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.is_reward — marquer les articles issus d'une récompense points
|
||||
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.is_reward: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.reward_pool_key — pool de points utilisé pour la récompense
|
||||
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS reward_pool_key VARCHAR(100) NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.reward_pool_key: %v", err)
|
||||
}
|
||||
|
||||
// Migration: command_items.is_reward + reward_pool_key
|
||||
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.is_reward: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS reward_pool_key VARCHAR(100) NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
|
||||
}
|
||||
|
||||
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
||||
if _, err = database.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'command_items' AND column_name = 'quantite'
|
||||
AND data_type = 'integer'
|
||||
) THEN
|
||||
ALTER TABLE command_items ALTER COLUMN quantite TYPE NUMERIC(10,3) USING quantite::NUMERIC(10,3);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.quantite: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne color pour les catégories
|
||||
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS color VARCHAR(7) NOT NULL DEFAULT '#7c3aed'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration categories.color: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne is_coming_soon pour les catégories
|
||||
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
|
||||
}
|
||||
|
||||
// Migration: position d'affichage des catégories
|
||||
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration categories.position: %v", err)
|
||||
}
|
||||
// Backfill: attribuer des positions aux catégories existantes (ordre alphabétique)
|
||||
if _, err = database.Exec(`
|
||||
UPDATE categories c
|
||||
SET position = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY name ASC) AS rn
|
||||
FROM categories
|
||||
) sub
|
||||
WHERE c.id = sub.id AND c.position = 0
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur backfill categories.position: %v", err)
|
||||
}
|
||||
|
||||
// Migration: table paramètres globaux de l'application
|
||||
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration app_settings: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne message pour les alertes police (type d'alerte)
|
||||
if _, err = database.Exec(`ALTER TABLE alerte_policy ADD COLUMN IF NOT EXISTS message VARCHAR(200) NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration alerte_policy.message: %v", err)
|
||||
}
|
||||
|
||||
// Migration: montant de parrainage utilisé pour la commande
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS referral_used FLOAT NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.referral_used: %v", err)
|
||||
}
|
||||
|
||||
// Migration: méthode de paiement (cash par défaut, crypto si paiement NowPayments)
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cash'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.payment_method: %v", err)
|
||||
}
|
||||
|
||||
// Migration: identifiant de commande par client (numérotation indépendante par client, commence à 1)
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS client_order_id INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.client_order_id: %v", err)
|
||||
}
|
||||
// Backfill: numéroter les commandes existantes par client dans l'ordre d'insertion
|
||||
if _, err = database.Exec(`
|
||||
UPDATE commandes c
|
||||
SET client_order_id = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY username ORDER BY id) AS rn
|
||||
FROM commandes
|
||||
) sub
|
||||
WHERE c.id = sub.id AND c.client_order_id = 0
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur backfill commandes.client_order_id: %v", err)
|
||||
}
|
||||
|
||||
// Migration: raison d'annulation par le client
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS cancel_reason TEXT`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.cancel_reason: %v", err)
|
||||
}
|
||||
|
||||
// Migration: coordonnées GPS de destination et du livreur
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS dest_latitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.dest_latitude: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS dest_longitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.dest_longitude: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS livreur_latitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.livreur_latitude: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS livreur_longitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.livreur_longitude: %v", err)
|
||||
}
|
||||
|
||||
// Migration: table de suivi des paiements crypto
|
||||
if _, err = database.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS crypto_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
nowpayment_id TEXT NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'waiting',
|
||||
price_amount NUMERIC(10,2) NOT NULL,
|
||||
price_currency VARCHAR(10) NOT NULL DEFAULT 'eur',
|
||||
pay_currency VARCHAR(20) NOT NULL,
|
||||
pay_address TEXT NOT NULL,
|
||||
pay_amount NUMERIC(20,8) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration crypto_payments: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_command_id ON crypto_payments(command_id)`); err != nil {
|
||||
log.Fatalf("❌ Erreur index crypto_payments.command_id: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_nowpayment_id ON crypto_payments(nowpayment_id)`); err != nil {
|
||||
log.Fatalf("❌ Erreur index crypto_payments.nowpayment_id: %v", err)
|
||||
}
|
||||
|
||||
// Migration: points extra pour tous les pools de points (stockage dynamique par clé)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_extra JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration clients.points_extra: %v", err)
|
||||
}
|
||||
|
||||
// Migration: récompenses réclamées par pool (nb de fois que la récompense a été obtenue)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_redeemed JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration clients.points_redeemed: %v", err)
|
||||
}
|
||||
|
||||
// Migration: flag "à venir" sur les produits
|
||||
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration products.coming_soon: %v", err)
|
||||
}
|
||||
|
||||
// Migration: prix actif/inactif sur les prix de produits
|
||||
if _, err = database.Exec(`ALTER TABLE product_prices ADD COLUMN IF NOT EXISTS active_price BOOLEAN NOT NULL DEFAULT TRUE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration product_prices.active_price: %v", err)
|
||||
}
|
||||
|
||||
// Migration: table contacts (SAV Telegram)
|
||||
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
)`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration contacts: %v", err)
|
||||
}
|
||||
|
||||
// Migration: clé RustFS pour les médias (stockage objet)
|
||||
if _, err = database.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS key TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration media.key: %v", err)
|
||||
}
|
||||
|
||||
// Migration: colonne parrain sur les clients (système de parrainage)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS parrain VARCHAR(255) DEFAULT NULL`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration clients.parrain: %v", err)
|
||||
}
|
||||
|
||||
// Migration: index sur clients.parrain (lookups filleuls + stats parrainage)
|
||||
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_clients_parrain ON clients(parrain) WHERE parrain IS NOT NULL`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration idx_clients_parrain: %v", err)
|
||||
}
|
||||
|
||||
// Lancer le nettoyage périodique des tokens expirés
|
||||
go database.cleanExpiredTokensPeriodically()
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRÉATION DES TABLES
|
||||
// ============================================
|
||||
|
||||
// createTables crée toutes les tables nécessaires
|
||||
func (db *Database) createTables() error {
|
||||
queries := []string{
|
||||
|
||||
// ============================
|
||||
// TABLE users
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'user',
|
||||
total NUMERIC(10,2) DEFAULT 0.0,
|
||||
livraison NUMERIC(10,2) DEFAULT 0.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE clients - ✅ AVEC COLONNES DE TRACKING DES ANNULATIONS
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS clients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
nom VARCHAR(100) NOT NULL,
|
||||
prenom VARCHAR(100) NOT NULL,
|
||||
telephone VARCHAR(20) NOT NULL UNIQUE,
|
||||
command INTEGER DEFAULT 0,
|
||||
amende NUMERIC(10,2) DEFAULT 0.0,
|
||||
cancel_commande INTEGER DEFAULT 0,
|
||||
cancellations_count INTEGER DEFAULT 0 NOT NULL,
|
||||
last_penalty_reason TEXT DEFAULT NULL,
|
||||
referral_balance NUMERIC(10,2) DEFAULT 0.0,
|
||||
parrain VARCHAR(255) DEFAULT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE jwt_tokens
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS jwt_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_type VARCHAR(20) NOT NULL CHECK (user_type IN ('client', 'admin', 'cabine', 'livreur')),
|
||||
token TEXT NOT NULL,
|
||||
date_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
date_fin TIMESTAMP NOT NULL,
|
||||
CHECK (date_fin > date_save)
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE categories
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
color VARCHAR(7) NOT NULL DEFAULT '#7c3aed',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE products
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(100) NOT NULL,
|
||||
stock DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
unit VARCHAR(10) NOT NULL DEFAULT 'u',
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE product_prices
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS product_prices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
quantity NUMERIC(10,3) NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(product_id, quantity)
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE media
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS media (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
key TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE commandes
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS commandes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
livreur_assign VARCHAR(50),
|
||||
adresse TEXT DEFAULT 'Adresse non spécifiée',
|
||||
total_prix NUMERIC(10,2) NOT NULL DEFAULT 0.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE command_items
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS command_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
produit VARCHAR(255) NOT NULL,
|
||||
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
|
||||
quantite INTEGER NOT NULL,
|
||||
prix NUMERIC(10,2) NOT NULL,
|
||||
-- Nouvelles colonnes pour les infos client
|
||||
client_username VARCHAR(255),
|
||||
client_nom VARCHAR(100),
|
||||
client_prenom VARCHAR(100),
|
||||
client_telephone VARCHAR(20),
|
||||
delivery_address TEXT,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE baskets
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS baskets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
quantity NUMERIC(10,3) NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE command_logs
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS command_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
author VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE delivery_issues
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS delivery_issues (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
issue_type VARCHAR(50) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
reported_by VARCHAR(100) NOT NULL,
|
||||
resolved_by VARCHAR(100),
|
||||
resolution TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS alerte_policy (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'false',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS adresse_correction (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invalid_address VARCHAR(255) NOT NULL UNIQUE,
|
||||
correct_address VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
// ============================
|
||||
// INDEXES - ✅ AJOUT D'INDEX POUR CANCELLATIONS_COUNT
|
||||
// ============================
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_command_id ON command_items(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_client_username ON command_items(client_username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_status ON command_items(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_produit ON command_items(produit);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_user_id ON jwt_tokens(user_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_user_type ON jwt_tokens(user_type);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_token ON jwt_tokens(token);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_date_fin ON jwt_tokens(date_fin);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_telephone ON clients(telephone);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_cancellations ON clients(cancellations_count);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_amende ON clients(amende) WHERE amende > 0;`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_username ON commandes(username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_status ON commandes(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_livreur ON commandes(livreur_assign);`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_product_id ON media(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_price_product_id ON product_prices(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_items_command_id ON command_items(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_items_product_id ON command_items(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_baskets_username ON baskets(username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_baskets_product_id ON baskets(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_logs_command_id ON command_logs(command_id);`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_command ON delivery_issues(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_status ON delivery_issues(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`,
|
||||
|
||||
// ============================
|
||||
// TABLE contacts
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE livreur_ratings
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS livreur_ratings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_id INTEGER NOT NULL UNIQUE REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
livreur_username VARCHAR(255) NOT NULL,
|
||||
client_username VARCHAR(255) NOT NULL,
|
||||
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_ratings_livreur ON livreur_ratings(livreur_username);`,
|
||||
|
||||
// ============================
|
||||
// TABLE login_history (livreur uniquement)
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS login_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_login_history_username ON login_history(username);`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
return fmt.Errorf("❌ Erreur SQL: %v\nRequête: %s", err, query)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("✅ Toutes les tables PostgreSQL créées avec succès.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MÉTHODES POUR LES TOKENS JWT
|
||||
// ============================================
|
||||
|
||||
// CleanExpiredTokens supprime les tokens JWT expirés
|
||||
func (d *Database) CleanExpiredTokens() error {
|
||||
query := `DELETE FROM jwt_tokens WHERE date_fin < $1`
|
||||
result, err := d.Exec(query, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected > 0 {
|
||||
log.Printf("🧹 %d token(s) expiré(s) supprimé(s)", rowsAffected)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanExpiredTokensPeriodically nettoie les tokens expirés toutes les heures
|
||||
func (db *Database) cleanExpiredTokensPeriodically() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if err := db.CleanExpiredTokens(); err != nil {
|
||||
log.Printf("⚠️ Erreur lors du nettoyage des tokens: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv récupère une variable d'environnement ou retourne une valeur par défaut
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type jwtToken struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int `gorm:"column:user_id;index"`
|
||||
UserType string `gorm:"column:user_type"`
|
||||
Token string `gorm:"column:token;uniqueIndex"`
|
||||
DateSave time.Time `gorm:"column:date_save;autoCreateTime"`
|
||||
DateFin time.Time `gorm:"column:date_fin"`
|
||||
}
|
||||
|
||||
func (jwtToken) TableName() string { return "jwt_tokens" }
|
||||
|
||||
var validTokenTypes = map[string]bool{
|
||||
"client": true, "admin": true, "cabine": true, "livreur": true,
|
||||
}
|
||||
|
||||
func (d *Database) SaveToken(userID int, userType string, token string, expiresAt time.Time) error {
|
||||
if !validTokenTypes[userType] {
|
||||
return fmt.Errorf("type d'utilisateur invalide: %s", userType)
|
||||
}
|
||||
t := jwtToken{UserID: userID, UserType: userType, Token: token, DateFin: expiresAt}
|
||||
if err := d.GDB.Create(&t).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors de l'enregistrement du token: %w", err)
|
||||
}
|
||||
log.Printf("✅ Token enregistré pour %s ID: %d", userType, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) IsTokenValid(token string) (bool, error) {
|
||||
var count int64
|
||||
err := d.GDB.Model(&jwtToken{}).
|
||||
Where("token = ? AND date_fin > ?", token, time.Now()).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (d *Database) RevokeToken(token string) error {
|
||||
result := d.GDB.Where("token = ?", token).Delete(&jwtToken{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la révocation du token: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("✅ Token révoqué avec succès")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) RevokeAllUserTokens(userID int, userType string) error {
|
||||
result := d.GDB.Where("user_id = ? AND user_type = ?", userID, userType).Delete(&jwtToken{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la révocation des tokens: %w", result.Error)
|
||||
}
|
||||
log.Printf("✅ %d token(s) révoqué(s) pour %s ID: %d", result.RowsAffected, userType, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserActiveTokens(userID int, userType string) ([]map[string]any, error) {
|
||||
var tokens []jwtToken
|
||||
err := d.GDB.Where("user_id = ? AND user_type = ? AND date_fin > ?", userID, userType, time.Now()).
|
||||
Order("date_save DESC").Find(&tokens).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des tokens: %w", err)
|
||||
}
|
||||
|
||||
result := make([]map[string]any, len(tokens))
|
||||
for i, t := range tokens {
|
||||
truncated := t.Token
|
||||
if len(truncated) > 20 {
|
||||
truncated = truncated[:20] + "..."
|
||||
}
|
||||
result[i] = map[string]any{
|
||||
"id": t.ID,
|
||||
"token": truncated,
|
||||
"date_save": t.DateSave,
|
||||
"date_fin": t.DateFin,
|
||||
"user_type": t.UserType,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetTokenInfo(token string) (map[string]any, error) {
|
||||
var t jwtToken
|
||||
err := d.GDB.Where("token = ? AND date_fin > ?", token, time.Now()).First(&t).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("token non trouvé ou expiré")
|
||||
}
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des infos du token: %w", err)
|
||||
}
|
||||
return map[string]any{
|
||||
"user_id": t.UserID,
|
||||
"user_type": t.UserType,
|
||||
"date_save": t.DateSave,
|
||||
"date_fin": t.DateFin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) CountActiveTokensByType() (map[string]int, error) {
|
||||
type row struct {
|
||||
UserType string
|
||||
Count int
|
||||
}
|
||||
var rows []row
|
||||
err := d.GDB.Model(&jwtToken{}).
|
||||
Select("user_type, COUNT(*) as count").
|
||||
Where("date_fin > ?", time.Now()).
|
||||
Group("user_type").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du comptage des tokens: %w", err)
|
||||
}
|
||||
counts := make(map[string]int, len(rows))
|
||||
for _, r := range rows {
|
||||
counts[r.UserType] = r.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type LivreurRating struct {
|
||||
ID int `json:"id"`
|
||||
OrderID int `json:"order_id"`
|
||||
LivreurUsername string `json:"livreur_username"`
|
||||
ClientUsername string `json:"client_username"`
|
||||
Rating int `json:"rating"`
|
||||
Comment string `json:"comment"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (d *Database) SubmitLivreurRating(orderID int, livreurUsername, clientUsername string, rating int, comment string) error {
|
||||
return d.GDB.Exec(`
|
||||
INSERT INTO livreur_ratings (order_id, livreur_username, client_username, rating, comment, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())
|
||||
`, orderID, livreurUsername, clientUsername, rating, comment).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetOrderRating(orderID int) (*LivreurRating, error) {
|
||||
var r LivreurRating
|
||||
err := d.GDB.Raw(`SELECT * FROM livreur_ratings WHERE order_id = ? LIMIT 1`, orderID).Scan(&r).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetLivreurRatings(livreurUsername string) ([]LivreurRating, float64, error) {
|
||||
var ratings []LivreurRating
|
||||
if err := d.GDB.Raw(`
|
||||
SELECT * FROM livreur_ratings WHERE livreur_username = ? ORDER BY created_at DESC LIMIT 200
|
||||
`, livreurUsername).Scan(&ratings).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var avg float64
|
||||
if len(ratings) > 0 {
|
||||
d.GDB.Raw(`SELECT COALESCE(AVG(rating), 0) FROM livreur_ratings WHERE livreur_username = ?`, livreurUsername).Scan(&avg)
|
||||
}
|
||||
return ratings, avg, nil
|
||||
}
|
||||
|
||||
// GetOrderForRating retourne l'username client et le livreur d'une commande approuvée
|
||||
func (d *Database) GetOrderForRating(orderID int) (clientUsername, livreurUsername string, err error) {
|
||||
var row struct {
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err = d.GDB.Raw(`
|
||||
SELECT username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? AND status = 'approved' LIMIT 1
|
||||
`, orderID).Scan(&row).Error
|
||||
return row.Username, row.LivreurAssign, err
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoginHistoryEntry struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// RecordLivreurLogin enregistre une connexion réussie d'un livreur (best-effort, non bloquant).
|
||||
func (d *Database) RecordLivreurLogin(username string) error {
|
||||
return d.GDB.Exec(`
|
||||
INSERT INTO login_history (username, created_at)
|
||||
VALUES (?, NOW())
|
||||
`, username).Error
|
||||
}
|
||||
|
||||
// GetLivreurLoginHistoryByMonth retourne le détail des connexions d'un livreur pour un mois donné,
|
||||
// triées du plus récent au plus ancien (max 50 entrées).
|
||||
func (d *Database) GetLivreurLoginHistoryByMonth(username string, year, month int) ([]LoginHistoryEntry, error) {
|
||||
var entries []LoginHistoryEntry
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, created_at FROM login_history
|
||||
WHERE username = ?
|
||||
AND EXTRACT(YEAR FROM created_at) = ?
|
||||
AND EXTRACT(MONTH FROM created_at) = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
`, username, year, month).Scan(&entries).Error
|
||||
return entries, err
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MediaInterface interface {
|
||||
GetProductID() int
|
||||
GetType() string
|
||||
GetURL() string
|
||||
SetID(int)
|
||||
}
|
||||
|
||||
// validateMediaID vérifie la validité d'un ID média
|
||||
func validateMediaID(mediaID int) error {
|
||||
if mediaID <= 0 {
|
||||
return fmt.Errorf("ID média invalide: %d", mediaID)
|
||||
}
|
||||
if mediaID > 2147483647 { // Max int32
|
||||
return fmt.Errorf("ID média trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateProductID vérifie la validité d'un ID produit
|
||||
func validateProductID(productID int) error {
|
||||
if productID <= 0 {
|
||||
return fmt.Errorf("ID produit invalide: %d", productID)
|
||||
}
|
||||
if productID > 2147483647 {
|
||||
return fmt.Errorf("ID produit trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMediaType vérifie le type de média
|
||||
func validateMediaType(mediaType string) error {
|
||||
validTypes := []string{"image", "video"}
|
||||
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||
if slices.Contains(validTypes, mediaType) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("type de média invalide: %s (autorisé: image, video)", mediaType)
|
||||
}
|
||||
|
||||
// validateMediaURL vérifie la sécurité de l'URL
|
||||
func validateMediaURL(url string) error {
|
||||
if len(url) == 0 {
|
||||
return fmt.Errorf("URL vide")
|
||||
}
|
||||
if len(url) > 500 {
|
||||
return fmt.Errorf("URL trop longue (max 500 caractères)")
|
||||
}
|
||||
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
|
||||
return fmt.Errorf("path traversal détecté dans l'URL")
|
||||
}
|
||||
if !strings.HasPrefix(url, "/uploads/") && !strings.HasPrefix(url, "/media/") {
|
||||
return fmt.Errorf("URL doit commencer par /uploads/ ou /media/")
|
||||
}
|
||||
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
|
||||
for _, char := range dangerousChars {
|
||||
if strings.Contains(url, char) {
|
||||
return fmt.Errorf("caractères interdits dans l'URL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateMedia(media any) error {
|
||||
log.Printf("🔒 [CreateMedia] START - Type: %T", media)
|
||||
|
||||
m, ok := media.(MediaInterface)
|
||||
if !ok {
|
||||
if mediaPtr, isPtr := media.(*models.Media); isPtr {
|
||||
m = mediaPtr
|
||||
ok = true
|
||||
} else {
|
||||
log.Printf("❌ [CreateMedia] Type invalide: %T", media)
|
||||
return fmt.Errorf("type de média invalide: reçu %T", media)
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("type de média invalide")
|
||||
}
|
||||
|
||||
productID := m.GetProductID()
|
||||
mediaType := m.GetType()
|
||||
mediaURL := m.GetURL()
|
||||
|
||||
mediaKey := ""
|
||||
if mediaPtr, isPtr := media.(*models.Media); isPtr {
|
||||
mediaKey = mediaPtr.Key
|
||||
}
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaType(mediaType); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaURL(mediaURL); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.InsertMedia(m, productID, mediaURL, mediaType, mediaKey); err != nil {
|
||||
log.Printf("❌ [InsertMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string, key string) error {
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
|
||||
return fmt.Errorf("erreur vérification produit: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [CreateMedia] Produit %d n'existe pas", productID)
|
||||
return fmt.Errorf("produit %d n'existe pas", productID)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ID int `gorm:"column:id"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO media (product_id, url, type, key, created_at)
|
||||
VALUES (?, ?, ?, ?, ?) RETURNING id`,
|
||||
productID, mediaURL, mediaType, key, time.Now(),
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
|
||||
return fmt.Errorf("erreur création média: %w", err)
|
||||
}
|
||||
|
||||
if mi, ok := m.(MediaInterface); ok {
|
||||
mi.SetID(result.ID)
|
||||
}
|
||||
log.Printf("✅ [CreateMedia] Média créé: ID=%d, Type=%s", result.ID, mediaType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
|
||||
log.Printf("🔍 [GetMediaByID] START - ID=%d", mediaID)
|
||||
|
||||
if err := validateMediaID(mediaID); err != nil {
|
||||
log.Printf("❌ [GetMediaByID] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var media models.Media
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, product_id, url, type, key, created_at
|
||||
FROM media WHERE id = ?`, mediaID).Scan(&media).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération média: %w", err)
|
||||
}
|
||||
if media.ID == 0 {
|
||||
log.Printf("❌ [GetMediaByID] Média %d non trouvé", mediaID)
|
||||
return nil, fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetMediaByID] Média trouvé: Type=%s", media.Type)
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
// GetMediaBatch charge les médias de plusieurs produits en une seule requête.
|
||||
func (d *Database) GetMediaBatch(productIDs []int) map[int][]models.Media {
|
||||
result := make(map[int][]models.Media, len(productIDs))
|
||||
if len(productIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
var mediaList []models.Media
|
||||
d.GDB.Raw(`SELECT id, product_id, url, type, key, created_at FROM media WHERE product_id IN ? ORDER BY product_id ASC, id ASC`, productIDs).Scan(&mediaList)
|
||||
for _, m := range mediaList {
|
||||
result[m.ProductID] = append(result[m.ProductID], m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
|
||||
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [GetMediaByProductID] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var mediaList []models.Media
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, product_id, url, type, key, created_at
|
||||
FROM media WHERE product_id = ?
|
||||
ORDER BY id ASC`, productID).Scan(&mediaList).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetMediaByProductID] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération médias: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetMediaByProductID] %d médias trouvés", len(mediaList))
|
||||
return mediaList, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateMedia(media *models.Media) error {
|
||||
if media == nil {
|
||||
return fmt.Errorf("média nil")
|
||||
}
|
||||
if err := validateMediaID(media.ID); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaType(media.Type); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaURL(media.URL); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := d.CheckMediaExists(media); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`UPDATE media SET url = ?, type = ? WHERE id = ?`, media.URL, media.Type, media.ID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [UpdateMedia] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour média: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [UpdateMedia] Aucune ligne affectée")
|
||||
return fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [UpdateMedia] Média %d mis à jour", media.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CheckMediaExists(media *models.Media) error {
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM media WHERE id = ?)`, media.ID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ [UpdateMedia] Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification média: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [UpdateMedia] Média %d n'existe pas", media.ID)
|
||||
return fmt.Errorf("média %d non trouvé", media.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteMedia(mediaID int) error {
|
||||
if err := validateMediaID(mediaID); err != nil {
|
||||
log.Printf("❌ [DeleteMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
media := models.Media{ID: mediaID}
|
||||
if err := d.CheckMediaExists(&media); err != nil {
|
||||
log.Printf("❌ [DeleteMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`DELETE FROM media WHERE id = ?`, mediaID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [DeleteMedia] Erreur DELETE: %v", result.Error)
|
||||
return fmt.Errorf("erreur suppression média: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [DeleteMedia] Aucune ligne affectée")
|
||||
return fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteMedia] Média %d supprimé", mediaID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteMediaByProductID(productID int) error {
|
||||
log.Printf("🗑️ [DeleteMediaByProductID] START - ProductID=%d", productID)
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification produit: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Produit %d n'existe pas", productID)
|
||||
return fmt.Errorf("produit %d non trouvé", productID)
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`DELETE FROM media WHERE product_id = ?`, productID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Erreur DELETE: %v", result.Error)
|
||||
return fmt.Errorf("erreur suppression médias: %w", result.Error)
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteMediaByProductID] %d média(s) supprimé(s) pour produit %d",
|
||||
result.RowsAffected, productID)
|
||||
return nil
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sendTelegramNotif envoie via le bot principal, puis lbtelegram (BOT1/BOT2) en fallback.
|
||||
func sendTelegramNotif(chatID int64, text string) {
|
||||
if err := services.TelegramBot.SendMessage(chatID, text); err != nil {
|
||||
log.Printf("⚠️ [NOTIF] bot principal échoué: %v — fallback lbtelegram", err)
|
||||
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
|
||||
if err2 := services.LBTelegram.SendNotification(chatID, text); err2 != nil {
|
||||
log.Printf("⚠️ [NOTIF] lbtelegram aussi échoué: %v", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error {
|
||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||
|
||||
notification := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": notifType,
|
||||
"message": message,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||
pipe.Exec(RedisCtx) //nolint
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok {
|
||||
dedupKey := fmt.Sprintf("notif:dedup:%d:%s", commandID, notifType)
|
||||
if set, _ := Redis.SetNX(RedisCtx, dedupKey, "1", 5*time.Minute).Result(); set {
|
||||
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
} else {
|
||||
log.Printf("⚠️ [NOTIF] Doublon détecté (cmd=%d, type=%s) — Telegram ignoré", commandID, notifType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📬 Notification envoyée à %s: %s", username, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
|
||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||
|
||||
notification := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": notifType,
|
||||
"message": message,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
pipe2 := Redis.Pipeline()
|
||||
pipe2.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe2.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe2.Expire(RedisCtx, notifKey, time.Hour)
|
||||
pipe2.Exec(RedisCtx) //nolint
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok {
|
||||
dedupKey := fmt.Sprintf("notif:dedup:livreur:%d:%s", commandID, notifType)
|
||||
if set, _ := Redis.SetNX(RedisCtx, dedupKey, "1", 5*time.Minute).Result(); set {
|
||||
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
} else {
|
||||
log.Printf("⚠️ [NOTIF] Doublon livreur détecté (cmd=%d, type=%s) — Telegram ignoré", commandID, notifType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📬 [LIVREUR_NOTIF] Notification envoyée à %s: %s", username, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
|
||||
var users []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.User{}).Select("username").Where("role IN ?", []string{"admin", "cabine"}).Scan(&users).Error; err != nil {
|
||||
log.Printf("❌ [ADMIN_NOTIF] Erreur lecture users admin/cabine: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Nouvelle commande #%d de %s — %s", commandID, clientUsername, deliveryAddr)
|
||||
|
||||
notification := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": "new_order",
|
||||
"message": msg,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
for _, u := range users {
|
||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||
}
|
||||
pipe.Exec(RedisCtx) //nolint
|
||||
|
||||
count := len(users)
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
for _, u := range users {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
||||
capturedChatID := chatID
|
||||
go sendTelegramNotif(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID)
|
||||
}
|
||||
|
||||
func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
|
||||
var users []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.User{}).Select("username").Where("role IN ?", []string{"admin", "cabine"}).Scan(&users).Error; err != nil {
|
||||
log.Printf("❌ [ALERT_NOTIF] Erreur lecture users admin/cabine: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
body := fmt.Sprintf("%s — livreur : %s", alertMessage, livreurUsername)
|
||||
|
||||
notification := map[string]any{
|
||||
"alert_id": alertID,
|
||||
"type": "alert",
|
||||
"message": body,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
for _, u := range users {
|
||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||
}
|
||||
pipe.Exec(RedisCtx) //nolint
|
||||
|
||||
count := len(users)
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
for _, u := range users {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
||||
capturedChatID := chatID
|
||||
go sendTelegramNotif(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", body))
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("🚨 [ALERT_NOTIF] Notif Redis (%d users) pour alerte #%d de %s", count, alertID, livreurUsername)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
|
||||
result := d.GDB.Model(&models.Client{}).
|
||||
Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername).
|
||||
Update("parrain", parrainUsername)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client introuvable ou parrain déjà défini")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetClientParrainAndCredit assigne un parrain à un client et crédite le parrain
|
||||
// dans une seule transaction, pour éviter un lien parrain enregistré sans le crédit associé.
|
||||
func (d *Database) SetClientParrainAndCredit(clientUsername, parrainUsername string, creditAmount float64) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&models.Client{}).
|
||||
Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername).
|
||||
Update("parrain", parrainUsername)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client introuvable ou parrain déjà défini")
|
||||
}
|
||||
|
||||
if creditAmount > 0 {
|
||||
result = tx.Model(&models.Client{}).Where("username = ?", parrainUsername).
|
||||
Updates(map[string]any{"referral_balance": gorm.Expr("referral_balance + ?", creditAmount)})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("parrain non trouvé")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) GetClientParrain(clientUsername string) (string, error) {
|
||||
var parrain sql.NullString
|
||||
err := d.GDB.Table("clients").
|
||||
Select("parrain").
|
||||
Where("username = ?", clientUsername).
|
||||
Scan(&parrain).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parrain.String, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
|
||||
var clients []models.Client
|
||||
err := d.GDB.
|
||||
Where("parrain = ?", parrainUsername).
|
||||
Find(&clients).Error
|
||||
return clients, err
|
||||
}
|
||||
|
||||
func (d *Database) GetParrainStats() ([]map[string]any, error) {
|
||||
var rows []struct {
|
||||
Parrain string `gorm:"column:parrain"`
|
||||
FilleulCount int `gorm:"column:filleul_count"`
|
||||
ReferralBalance float64 `gorm:"column:referral_balance"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
c.parrain,
|
||||
COUNT(c.username) AS filleul_count,
|
||||
p.referral_balance
|
||||
FROM clients c
|
||||
JOIN clients p ON p.username = c.parrain
|
||||
WHERE c.parrain IS NOT NULL AND c.parrain <> ''
|
||||
GROUP BY c.parrain, p.referral_balance
|
||||
ORDER BY filleul_count DESC
|
||||
`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
result = append(result, map[string]any{
|
||||
"parrain": r.Parrain,
|
||||
"filleul_count": r.FilleulCount,
|
||||
"referral_balance": r.ReferralBalance,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) CreateCryptoPayment(commandID int, nowPaymentID, status, priceCurrency, payCurrency, payAddress string, priceAmount, payAmount float64) (*models.CryptoPayment, error) {
|
||||
p := models.CryptoPayment{
|
||||
CommandID: commandID,
|
||||
NowPaymentID: nowPaymentID,
|
||||
Status: status,
|
||||
PriceAmount: priceAmount,
|
||||
PriceCurrency: priceCurrency,
|
||||
PayCurrency: payCurrency,
|
||||
PayAddress: payAddress,
|
||||
PayAmount: payAmount,
|
||||
}
|
||||
if err := d.GDB.Create(&p).Error; err != nil {
|
||||
return nil, fmt.Errorf("CreateCryptoPayment: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCryptoPaymentByCommandID(commandID int) (*models.CryptoPayment, error) {
|
||||
var p models.CryptoPayment
|
||||
err := d.GDB.Where("command_id = ?", commandID).Order("created_at DESC").First(&p).Error
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCryptoPaymentByNowPaymentID(nowPaymentID string) (*models.CryptoPayment, error) {
|
||||
var p models.CryptoPayment
|
||||
err := d.GDB.Where("nowpayment_id = ?", nowPaymentID).First(&p).Error
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetPendingCryptoPayments() ([]models.CryptoPayment, error) {
|
||||
var payments []models.CryptoPayment
|
||||
err := d.GDB.Where("status NOT IN ?", []string{"finished", "failed", "expired", "refunded"}).
|
||||
Order("created_at ASC").Find(&payments).Error
|
||||
return payments, err
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCryptoPaymentStatus(id int, status string, payAmount float64) error {
|
||||
return d.GDB.Model(&models.CryptoPayment{}).Where("id = ?", id).
|
||||
Updates(map[string]any{"status": status, "pay_amount": payAmount}).Error
|
||||
}
|
||||
|
||||
func (d *Database) ActivateCryptoCommand(commandID int) error {
|
||||
return d.GDB.Exec(`UPDATE commandes SET status = 'pending', updated_at = NOW() WHERE id = ? AND status = 'pending_payment'`, commandID).Error
|
||||
}
|
||||
|
||||
func (d *Database) CancelCryptoCommand(commandID int) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdStatus string
|
||||
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cmdStatus == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
if cmdStatus != "pending_payment" {
|
||||
return fmt.Errorf("commande non annulable (statut: %s)", cmdStatus)
|
||||
}
|
||||
|
||||
type item struct {
|
||||
ProductID int
|
||||
Quantite float64
|
||||
}
|
||||
var items []item
|
||||
if err := tx.Raw(`SELECT product_id, quantite FROM command_items WHERE command_id = ?`, commandID).Scan(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range items {
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, it.Quantite, it.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur restauration stock produit %d: %w", it.ProductID, err)
|
||||
}
|
||||
}
|
||||
return tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = NOW() WHERE id = ?`, commandID).Error
|
||||
})
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreateProduct crée un nouveau produit avec ses prix
|
||||
func (d *Database) CreateProduct(product any) error {
|
||||
log.Printf("🔍 [DB CreateProduct] Type reçu: %T", product)
|
||||
log.Printf("🔍 [DB CreateProduct] Valeur: %+v", product)
|
||||
|
||||
type ProductInterface interface {
|
||||
GetName() string
|
||||
GetCategory() string
|
||||
GetDescription() string
|
||||
GetStock() float64
|
||||
GetUnit() string
|
||||
GetPrices() []models.ProductPrice
|
||||
SetID(int)
|
||||
SetCreatedAt(time.Time)
|
||||
SetUpdatedAt(time.Time)
|
||||
}
|
||||
|
||||
p, ok := product.(ProductInterface)
|
||||
if !ok {
|
||||
if prodPtr, isPtr := product.(*models.Product); isPtr {
|
||||
log.Printf("✅ [DB CreateProduct] C'est un *models.Product, utilisons-le directement")
|
||||
p = prodPtr
|
||||
ok = true
|
||||
} else {
|
||||
return fmt.Errorf("type de produit invalide: reçu %T, attendu ProductInterface", product)
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
log.Printf("✅ [DB CreateProduct] Assertion réussie!")
|
||||
log.Printf("📦 [DB CreateProduct] Name: %s", p.GetName())
|
||||
log.Printf("📦 [DB CreateProduct] Category: %s", p.GetCategory())
|
||||
log.Printf("📦 [DB CreateProduct] Description: %s", p.GetDescription())
|
||||
log.Printf("📦 [DB CreateProduct] Stock: %.2f", p.GetStock())
|
||||
log.Printf("📦 [DB CreateProduct] Unit: %s", p.GetUnit())
|
||||
log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices()))
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var result struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
comingSoonVal := false
|
||||
if prodModel, ok2 := product.(*models.Product); ok2 {
|
||||
comingSoonVal = prodModel.ComingSoon
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO products (name, category, description, stock, unit, coming_soon, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
|
||||
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), comingSoonVal, now, now,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
|
||||
return fmt.Errorf("erreur création produit: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [DB CreateProduct] Produit inséré avec ID: %d", result.ID)
|
||||
|
||||
p.SetID(result.ID)
|
||||
p.SetCreatedAt(result.CreatedAt)
|
||||
p.SetUpdatedAt(result.UpdatedAt)
|
||||
|
||||
if rawPrices := p.GetPrices(); len(rawPrices) > 0 {
|
||||
priceRows := make([]models.ProductPrice, len(rawPrices))
|
||||
for i, price := range rawPrices {
|
||||
priceRows[i] = models.ProductPrice{
|
||||
ProductID: result.ID,
|
||||
Quantity: price.Quantity,
|
||||
Price: price.Price,
|
||||
ActivePrice: price.ActivePrice,
|
||||
}
|
||||
}
|
||||
if err := d.GDB.Create(&priceRows).Error; err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur insertion prix batch: %v", err)
|
||||
return fmt.Errorf("erreur insertion prix: %v", err)
|
||||
}
|
||||
log.Printf("✅ [DB CreateProduct] %d prix insérés", len(priceRows))
|
||||
}
|
||||
|
||||
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProductByID récupère un produit par son ID avec prices ET stock
|
||||
func (d *Database) GetProductByID(id int) (models.Product, error) {
|
||||
log.Printf("📦 [GetProductByID] START - ID=%d", id)
|
||||
|
||||
var p models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
FROM products
|
||||
WHERE id = ?`, id).Scan(&p).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductByID] Erreur query: %v", err)
|
||||
return p, err
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetProductByID] Product scanned: ID=%d, Name=%s, Stock=%.2f", p.ID, p.Name, p.Stock)
|
||||
|
||||
prices, err := d.GetProductPrices(p.ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetProductByID] Erreur loading prices: %v", err)
|
||||
p.Prices = []models.ProductPrice{}
|
||||
} else {
|
||||
p.Prices = prices
|
||||
log.Printf("✅ [GetProductByID] Loaded %d prices for product %d", len(prices), p.ID)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetProductNamesByIDs retourne un map id→name pour une liste d'IDs.
|
||||
func (d *Database) GetProductNamesByIDs(ids []int) (map[int]string, error) {
|
||||
result := make(map[int]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
rows, err := d.GDB.Raw(`SELECT id, name FROM products WHERE id IN ?`, ids).Rows()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var name string
|
||||
if err := rows.Scan(&id, &name); err == nil {
|
||||
result[id] = name
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetProductCategoriesByIDs retourne un map id→category pour une liste d'IDs.
|
||||
func (d *Database) GetProductCategoriesByIDs(ids []int) (map[int]string, error) {
|
||||
result := make(map[int]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
rows, err := d.GDB.Raw(`SELECT id, category FROM products WHERE id IN ?`, ids).Rows()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var category string
|
||||
if err := rows.Scan(&id, &category); err == nil {
|
||||
result[id] = category
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||
log.Println("📦 [GetAllProducts] START")
|
||||
|
||||
var products []models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
FROM products
|
||||
ORDER BY id ASC`).Scan(&products).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetAllProducts] Erreur query: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
productIDs := make([]int, len(products))
|
||||
for i, p := range products {
|
||||
productIDs[i] = p.ID
|
||||
}
|
||||
allPrices := d.GetProductPricesBatch(productIDs)
|
||||
allMedia := d.GetMediaBatch(productIDs)
|
||||
for i := range products {
|
||||
if prices, ok := allPrices[products[i].ID]; ok {
|
||||
products[i].Prices = prices
|
||||
} else {
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
}
|
||||
if media, ok := allMedia[products[i].ID]; ok {
|
||||
products[i].Media = media
|
||||
} else {
|
||||
products[i].Media = []models.Media{}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetAllProducts] Total products loaded: %d", len(products))
|
||||
return products, nil
|
||||
}
|
||||
|
||||
// GetProductsByCategory récupère les produits par catégorie avec prices ET stock
|
||||
func (d *Database) GetProductsByCategory(category string) ([]models.Product, error) {
|
||||
log.Printf("📦 [GetProductsByCategory] START - Category=%s", category)
|
||||
|
||||
var products []models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
FROM products
|
||||
WHERE category = ?
|
||||
ORDER BY created_at DESC`, category).Scan(&products).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductsByCategory] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
|
||||
}
|
||||
|
||||
catProductIDs := make([]int, len(products))
|
||||
for i, p := range products {
|
||||
catProductIDs[i] = p.ID
|
||||
}
|
||||
catPrices := d.GetProductPricesBatch(catProductIDs)
|
||||
for i := range products {
|
||||
if prices, ok := catPrices[products[i].ID]; ok {
|
||||
products[i].Prices = prices
|
||||
} else {
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetProductsByCategory] Total products loaded: %d", len(products))
|
||||
return products, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateProduct(productID int, name, category, description, unit string, comingSoon bool, prices []models.ProductPrice) error {
|
||||
err := d.GDB.Exec(`
|
||||
UPDATE products
|
||||
SET name = ?, category = ?, description = ?, unit = ?, coming_soon = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
name, category, description, unit, comingSoon, time.Now(), productID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour produit: %w", err)
|
||||
}
|
||||
|
||||
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
|
||||
|
||||
if len(prices) > 0 {
|
||||
priceRows := make([]models.ProductPrice, len(prices))
|
||||
for i, price := range prices {
|
||||
priceRows[i] = models.ProductPrice{
|
||||
ProductID: productID,
|
||||
Quantity: price.Quantity,
|
||||
Price: price.Price,
|
||||
ActivePrice: price.ActivePrice,
|
||||
}
|
||||
}
|
||||
if err := d.GDB.Create(&priceRows).Error; err != nil {
|
||||
return fmt.Errorf("erreur insertion prix: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProductStock fixe le stock à une valeur absolue. Le verrou FOR UPDATE
|
||||
// sérialise cette écriture avec les décréments du checkout (db_commands.go) :
|
||||
// sans lui, une modification admin pourrait écraser silencieusement le
|
||||
// décrément d'une commande passée au même instant sur le même produit.
|
||||
func (d *Database) SetProductStock(productID int, stock float64) error {
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var exists int
|
||||
if err := tx.Raw(`SELECT 1 FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&exists).Error; err != nil {
|
||||
return fmt.Errorf("erreur verrouillage produit: %w", err)
|
||||
}
|
||||
if exists == 0 {
|
||||
return fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
if err := tx.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
|
||||
stock, time.Now(), productID).Error; err != nil {
|
||||
return fmt.Errorf("erreur mise à jour stock: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) SetProductComingSoon(productID int, comingSoon bool) error {
|
||||
result := d.GDB.Exec(`UPDATE products SET coming_soon = ?, updated_at = ? WHERE id = ?`,
|
||||
comingSoon, time.Now(), productID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour coming_soon: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProduct supprime un produit
|
||||
func (d *Database) DeleteProduct(productID int) error {
|
||||
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur suppression produit: %v", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit introuvable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductNameByID(productID int) (string, error) {
|
||||
var result struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT name FROM products WHERE id = ?`, productID).Scan(&result).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Name == "" {
|
||||
return "", fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
return result.Name, nil
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error) {
|
||||
var prices []models.ProductPrice
|
||||
if err := d.GDB.Where("product_id = ?", productID).Order("quantity ASC").Find(&prices).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération prix: %w", err)
|
||||
}
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
// GetProductPricesBatch charge les prix de plusieurs produits en une seule requête.
|
||||
func (d *Database) GetProductPricesBatch(productIDs []int) map[int][]models.ProductPrice {
|
||||
result := make(map[int][]models.ProductPrice, len(productIDs))
|
||||
if len(productIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
var prices []models.ProductPrice
|
||||
d.GDB.Where("product_id IN ?", productIDs).Order("product_id ASC, quantity ASC").Find(&prices)
|
||||
for _, p := range prices {
|
||||
result[p.ProductID] = append(result[p.ProductID], p)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *Database) AddActivePrice(priceID int) error {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).
|
||||
Where("id = ?", priceID).
|
||||
Update("active_price", true)
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeActivePrice(priceID int) error {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).
|
||||
Where("id = ?", priceID).
|
||||
Update("active_price", false)
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
// ============================================
|
||||
// db/queue_auto_next_db.go
|
||||
// GESTION AUTOMATIQUE PROCHAINE COMMANDE
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// ProcessNextCommandForDeliveryman traite automatiquement la prochaine commande
|
||||
func (d *Database) ProcessNextCommandForDeliveryman(deliveryman string) error {
|
||||
log.Printf("🔄 [NEXT_COMMAND] Traitement prochaine commande pour %s", deliveryman)
|
||||
|
||||
// Récupérer la prochaine commande dans sa queue
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
// Pas de commande dans la queue personnelle
|
||||
log.Printf("ℹ️ [NEXT_COMMAND] Aucune commande en queue pour %s", deliveryman)
|
||||
|
||||
// Mettre le livreur en "available"
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
|
||||
// ✅ Mettre à jour le statut basé sur la queue
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
log.Printf("⚠️ [NEXT_COMMAND] ID invalide: %s", cmdIDStr)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("🔍 [NEXT_COMMAND] Vérification commande %d (position %d/%d)", commandID, i+1, len(commandIDs))
|
||||
|
||||
// Récupérer les détails de la commande depuis Redis
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [NEXT_COMMAND] Commande %d: données Redis introuvables - Retrait", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue // Essayer la suivante
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
log.Printf("❌ [NEXT_COMMAND] Commande %d: JSON invalide - Retrait", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ Vérifier le statut de la commande dans la DB
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [NEXT_COMMAND] Commande %d non trouvée en DB - Retrait de la queue", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue // Essayer la suivante
|
||||
}
|
||||
|
||||
currentStatus, _ := command["status"].(string)
|
||||
log.Printf("📊 [NEXT_COMMAND] Commande %d: statut = '%s'", commandID, currentStatus)
|
||||
|
||||
nonAssignableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
|
||||
if slices.Contains(nonAssignableStatuses, currentStatus) {
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue
|
||||
}
|
||||
|
||||
go d.OptimizeDeliverymanQueueByProximity(deliveryman)
|
||||
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
d.AddCommandLog(commandID, "next_in_queue",
|
||||
fmt.Sprintf("Commande suivante dans la queue de %s", deliveryman),
|
||||
"system")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mettre le livreur en available
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCommandFromDeliverymanQueue retire une commande spécifique de la queue d'un livreur
|
||||
func (d *Database) RemoveCommandFromDeliverymanQueue(deliveryman string, commandID int) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
log.Printf("📦 [RemoveFromDeliverymanQueue] Retrait cmd %d de la queue de %s", commandID, deliveryman)
|
||||
|
||||
// Retirer de la queue
|
||||
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [RemoveFromDeliverymanQueue] Erreur: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result == 0 {
|
||||
log.Printf("⚠️ [RemoveFromDeliverymanQueue] Commande %d non trouvée dans queue", commandID)
|
||||
} else {
|
||||
log.Printf("✅ [RemoveFromDeliverymanQueue] Commande %d retirée", commandID)
|
||||
}
|
||||
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupCompletedCommandFromQueue nettoie une commande terminée et prépare la suivante
|
||||
func (d *Database) CleanupCompletedCommandFromQueue(commandID int, deliveryman string) error {
|
||||
log.Printf("🧹 [CLEANUP] Nettoyage commande %d pour %s", commandID, deliveryman)
|
||||
|
||||
// 1. Retirer la commande de TOUTES les queues (pas seulement celle du livreur)
|
||||
err := d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLEANUP] Erreur retrait commande: %v", err)
|
||||
}
|
||||
|
||||
// 2. Supprimer les caches associés
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
Redis.Del(RedisCtx, destCacheKey)
|
||||
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// 3. Supprimer la clé de données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
log.Printf("✅ [CLEANUP] Commande %d nettoyée complètement", commandID)
|
||||
|
||||
// 4. Traiter la prochaine commande
|
||||
return d.ProcessNextCommandForDeliveryman(deliveryman)
|
||||
}
|
||||
|
||||
// RemoveCommandFromAllQueues retire une commande de toutes les queues Redis
|
||||
func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string) error {
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
log.Printf("🗑️ [REMOVE_ALL] Suppression commande %d de toutes les queues", commandID)
|
||||
|
||||
// 1. Queue du livreur spécifique
|
||||
if deliveryman != "" {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Erreur suppression queue livreur: %v", err)
|
||||
} else if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:%s", queueKey)
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
} else {
|
||||
log.Printf("ℹ️ [REMOVE_ALL] Commande %d n'était pas dans queue:%s", commandID, queueKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Queue générale
|
||||
removed := false
|
||||
result, _ := Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:pending:sorted")
|
||||
removed = true
|
||||
}
|
||||
|
||||
// 3. Queue prioritaire
|
||||
result, _ = Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:priority:sorted")
|
||||
removed = true
|
||||
}
|
||||
|
||||
// 4. Vérifier toutes les autres queues de livreurs (au cas où)
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
result, _ := Redis.ZRem(RedisCtx, key, commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Commande trouvée et supprimée de %s", key)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Supprimer la clé de données
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
result, _ = Redis.Del(RedisCtx, commandKey).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Données supprimées: %s", commandKey)
|
||||
removed = true
|
||||
}
|
||||
|
||||
if !removed {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Commande %d n'a été trouvée dans aucune queue", commandID)
|
||||
} else {
|
||||
log.Printf("✅ [REMOVE_ALL] Commande %d supprimée de toutes les queues", commandID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) GetClientReferralBalance(username string) (float64, error) {
|
||||
var balance float64
|
||||
err := d.GDB.Table("clients").Select("referral_balance").Where("username = ?", username).Scan(&balance).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreditClientReferral(username string, amount float64) error {
|
||||
if amount <= 0 {
|
||||
return fmt.Errorf("le montant doit être positif")
|
||||
}
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
|
||||
"referral_balance": gorm.Expr("referral_balance + ?", amount),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DebitReferralBalance(username string, amount float64) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var balance float64
|
||||
if err := tx.Raw(`SELECT referral_balance FROM clients WHERE username = ? FOR UPDATE`, username).Scan(&balance).Error; err != nil {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
if balance < amount {
|
||||
return fmt.Errorf("solde parrainage insuffisant (disponible: %.2f€)", balance)
|
||||
}
|
||||
return tx.Exec(`UPDATE clients SET referral_balance = referral_balance - ? WHERE username = ?`, amount, username).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientReferralBalance(username string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("referral_balance", 0)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// ============================================
|
||||
// db/cancel_sanctions_db.go
|
||||
// GESTION DES SANCTIONS ÉVOLUTIVES
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetClientCancellationsCount récupère le nombre d'annulations tardives d'un client
|
||||
func (d *Database) GetClientCancellationsCount(username string) (int, error) {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Table("clients").Select("COALESCE(cancellations_count, 0) as count").Where("username = ?", username).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetCancellationsCount] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur récupération compteur: %w", err)
|
||||
}
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
// penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné
|
||||
func penaltyForCount(count int, tiers []models.PenaltyTier) int {
|
||||
if len(tiers) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := make([]models.PenaltyTier, len(tiers))
|
||||
copy(sorted, tiers)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].MinCancel > sorted[j].MinCancel
|
||||
})
|
||||
for _, t := range sorted {
|
||||
if count >= t.MinCancel {
|
||||
return t.Amount
|
||||
}
|
||||
}
|
||||
return sorted[len(sorted)-1].Amount
|
||||
}
|
||||
|
||||
// penaltyTiers charge le barème de pénalités configuré, avec repli sur le barème par défaut si les settings sont indisponibles
|
||||
func (d *Database) penaltyTiers(logCtx string) []models.PenaltyTier {
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [%s] Impossible de charger les settings, barème par défaut: %v", logCtx, err)
|
||||
settings = DefaultSettings()
|
||||
}
|
||||
return settings.PenaltyTiers
|
||||
}
|
||||
|
||||
// CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré
|
||||
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
||||
count, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
penalty := penaltyForCount(count, d.penaltyTiers("CalculatePenalty"))
|
||||
|
||||
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
|
||||
username, count, penalty)
|
||||
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// ApplyCancellationPenalty applique une pénalité (cumulative) et incrémente le compteur d'annulations.
|
||||
// Verrouillée via FOR UPDATE pour éviter qu'un appel concurrent (même client, deux livraisons en parallèle)
|
||||
// calcule la pénalité sur un compteur pas encore à jour, et l'amende s'additionne au lieu d'écraser
|
||||
// le solde existant (cohérent avec CancelCommandAtomic pour l'annulation côté client).
|
||||
func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
|
||||
tiers := d.penaltyTiers("ApplyCancellationPenalty")
|
||||
|
||||
var penalty int
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var count int
|
||||
if err := tx.Raw(`
|
||||
SELECT COALESCE(cancellations_count, 0) FROM clients
|
||||
WHERE username = ? FOR UPDATE`, username).Scan(&count).Error; err != nil {
|
||||
return fmt.Errorf("erreur récupération compteur: %w", err)
|
||||
}
|
||||
|
||||
penalty = penaltyForCount(count, tiers)
|
||||
|
||||
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
|
||||
amende = amende + ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, penalty, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur application pénalité: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// GetClientCancellationHistory récupère l'historique d'annulations d'un client
|
||||
func (d *Database) GetClientCancellationHistory(username string) (map[string]any, error) {
|
||||
nextPenalty, err := d.CalculateCancellationPenalty(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings, _ := d.GetSettings()
|
||||
|
||||
client, err := d.GetClientByUsername(username)
|
||||
var currentAmende float64
|
||||
if err == nil {
|
||||
currentAmende = client.Amende
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"cancellations_count": count,
|
||||
"current_amende": currentAmende,
|
||||
"next_penalty": nextPenalty,
|
||||
"penalty_tiers": settings.PenaltyTiers,
|
||||
"warning": "Une amende sera appliquée lors de la prochaine annulation tardive",
|
||||
}, nil
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DefaultDeliverySchedule retourne un planning de livraison par défaut (tous les jours, 9h-20h)
|
||||
func DefaultDeliverySchedule() models.DeliverySchedule {
|
||||
day := models.DaySchedule{Enabled: true, OpenTime: "09:00", CloseTime: "20:00"}
|
||||
return models.DeliverySchedule{
|
||||
Monday: day, Tuesday: day, Wednesday: day, Thursday: day,
|
||||
Friday: day, Saturday: day, Sunday: day,
|
||||
}
|
||||
}
|
||||
|
||||
// CalcPointsFromTiers retourne le nombre de points correspondant au total selon les paliers
|
||||
func CalcPointsFromTiers(total float64, tiers []models.PointsTier) int {
|
||||
for _, t := range tiers {
|
||||
if total >= t.Min && (t.Max == 0 || total <= t.Max) {
|
||||
return t.Points
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// DefaultSettings retourne les paramètres par défaut
|
||||
func DefaultSettings() models.AppSettings {
|
||||
return models.AppSettings{
|
||||
PenaltiesEnabled: true,
|
||||
ShowAmendeScore: true,
|
||||
PenaltyTiers: []models.PenaltyTier{
|
||||
{MinCancel: 0, Amount: 20},
|
||||
{MinCancel: 1, Amount: 50},
|
||||
{MinCancel: 2, Amount: 100},
|
||||
{MinCancel: 3, Amount: 150},
|
||||
},
|
||||
PointsEnabled: true,
|
||||
ReferralEnabled: true,
|
||||
ReferralAmount: 0,
|
||||
PointsPools: []models.PointsPool{
|
||||
{
|
||||
Key: "pool_0",
|
||||
Name: "Pool 1",
|
||||
Categories: []string{},
|
||||
Tiers: []models.PointsTier{
|
||||
{Min: 30, Max: 50, Points: 1},
|
||||
{Min: 60, Max: 150, Points: 2},
|
||||
{Min: 160, Max: 300, Points: 3},
|
||||
{Min: 310, Max: 400, Points: 5},
|
||||
{Min: 401, Max: 0, Points: 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "pool_1",
|
||||
Name: "Pool 2",
|
||||
Categories: []string{},
|
||||
Tiers: []models.PointsTier{
|
||||
{Min: 30, Max: 100, Points: 1},
|
||||
{Min: 110, Max: 200, Points: 2},
|
||||
{Min: 210, Max: 0, Points: 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
ShopName: "Milieu-Nantais",
|
||||
ContactTelegram: "MLN44LA",
|
||||
DeliveryMode: models.DeliveryModeConfig{
|
||||
Mode: "single",
|
||||
CategoryRoutes: []models.CategoryRoute{},
|
||||
},
|
||||
AdminColorPrimary: "#7c3aed",
|
||||
AdminColorSecondary: "#000000",
|
||||
AdminColorSuccess: "#4ade80",
|
||||
AdminColorDanger: "#ef4444",
|
||||
AdminColorWarning: "#f59e0b",
|
||||
ClientColorPrimary: "#7c3aed",
|
||||
ClientColorSecondary: "#000000",
|
||||
ClientColorSuccess: "#4ade80",
|
||||
ClientColorDanger: "#ef4444",
|
||||
ClientColorWarning: "#f59e0b",
|
||||
ClientTitleGradientFrom: "#a78bfa",
|
||||
ClientTitleGradientTo: "#22d3ee",
|
||||
DeliverySchedule: DefaultDeliverySchedule(),
|
||||
PostalZones: []models.PostalZone{
|
||||
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
|
||||
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
|
||||
"44400", "44880", "44120", "44230", "44115",
|
||||
"44980", "44470", "44240", "44700", "44800", "44340", "44620", "44830",
|
||||
}},
|
||||
{Name: "Zone 100€", MinAmount: 100, Codes: []string{
|
||||
"44860", "44220", "44118", "44710", "44690", "44119",
|
||||
}},
|
||||
},
|
||||
Telegram2FAEnabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSettings récupère les paramètres depuis la DB
|
||||
func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
settings := DefaultSettings()
|
||||
|
||||
var rows []struct {
|
||||
Key string `gorm:"column:key"`
|
||||
Value string `gorm:"column:value"`
|
||||
}
|
||||
if err := d.GDB.Table("app_settings").Select("key, value").Scan(&rows).Error; err != nil {
|
||||
return settings, fmt.Errorf("erreur lecture settings: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
switch row.Key {
|
||||
case "penalties_enabled":
|
||||
settings.PenaltiesEnabled = row.Value == "true"
|
||||
case "show_amende_score":
|
||||
settings.ShowAmendeScore = row.Value == "true"
|
||||
case "points_enabled":
|
||||
settings.PointsEnabled = row.Value == "true"
|
||||
case "points_pools":
|
||||
var pools []models.PointsPool
|
||||
if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
|
||||
settings.PointsPools = pools
|
||||
}
|
||||
case "points_reward":
|
||||
var reward models.PointsReward
|
||||
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
|
||||
settings.PointsReward = &reward
|
||||
}
|
||||
case "referral_enabled":
|
||||
settings.ReferralEnabled = row.Value == "true"
|
||||
case "referral_amount":
|
||||
if v, err := strconv.ParseFloat(row.Value, 64); err == nil {
|
||||
settings.ReferralAmount = v
|
||||
}
|
||||
case "crypto_payment_enabled":
|
||||
settings.CryptoPaymentEnabled = row.Value == "true"
|
||||
case "crypto_only":
|
||||
settings.CryptoOnly = row.Value == "true"
|
||||
case "nowpayments_api_key":
|
||||
settings.NowPaymentsAPIKey = row.Value
|
||||
case "nowpayments_ipn_secret":
|
||||
settings.NowPaymentsIPNSecret = row.Value
|
||||
case "nowpayments_currencies":
|
||||
var currencies []string
|
||||
if err := json.Unmarshal([]byte(row.Value), ¤cies); err == nil {
|
||||
settings.NowPaymentsCurrencies = currencies
|
||||
}
|
||||
case "delivery_schedule":
|
||||
var sched models.DeliverySchedule
|
||||
if err := json.Unmarshal([]byte(row.Value), &sched); err == nil {
|
||||
settings.DeliverySchedule = sched
|
||||
}
|
||||
case "postal_zones":
|
||||
var zones []models.PostalZone
|
||||
if err := json.Unmarshal([]byte(row.Value), &zones); err == nil {
|
||||
settings.PostalZones = zones
|
||||
}
|
||||
case "contact_telegram":
|
||||
settings.ContactTelegram = row.Value
|
||||
case "telegram_bot_token":
|
||||
settings.TelegramBotToken = row.Value
|
||||
case "telegram_bot_username":
|
||||
settings.TelegramBotUsername = row.Value
|
||||
case "telegram_notifications_enabled":
|
||||
settings.TelegramNotificationsEnabled = row.Value == "true"
|
||||
case "delivery_mode":
|
||||
var mode models.DeliveryModeConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &mode); err == nil {
|
||||
settings.DeliveryMode = mode
|
||||
}
|
||||
case "telegram_2fa_enabled":
|
||||
settings.Telegram2FAEnabled = row.Value == "true"
|
||||
case "shop_name":
|
||||
settings.ShopName = row.Value
|
||||
case "admin_color_primary":
|
||||
settings.AdminColorPrimary = row.Value
|
||||
case "admin_color_secondary":
|
||||
settings.AdminColorSecondary = row.Value
|
||||
case "admin_color_success":
|
||||
settings.AdminColorSuccess = row.Value
|
||||
case "admin_color_danger":
|
||||
settings.AdminColorDanger = row.Value
|
||||
case "admin_color_warning":
|
||||
settings.AdminColorWarning = row.Value
|
||||
case "client_color_primary":
|
||||
settings.ClientColorPrimary = row.Value
|
||||
case "client_color_secondary":
|
||||
settings.ClientColorSecondary = row.Value
|
||||
case "client_color_success":
|
||||
settings.ClientColorSuccess = row.Value
|
||||
case "client_color_danger":
|
||||
settings.ClientColorDanger = row.Value
|
||||
case "client_color_warning":
|
||||
settings.ClientColorWarning = row.Value
|
||||
case "client_title_gradient_from":
|
||||
settings.ClientTitleGradientFrom = row.Value
|
||||
case "client_title_gradient_to":
|
||||
settings.ClientTitleGradientTo = row.Value
|
||||
}
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// UpdateSettings sauvegarde les paramètres dans la DB
|
||||
func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
boolStr := func(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
if s.PointsPools == nil {
|
||||
s.PointsPools = []models.PointsPool{}
|
||||
}
|
||||
for i := range s.PointsPools {
|
||||
if s.PointsPools[i].Categories == nil {
|
||||
s.PointsPools[i].Categories = []string{}
|
||||
}
|
||||
if s.PointsPools[i].Tiers == nil {
|
||||
s.PointsPools[i].Tiers = []models.PointsTier{}
|
||||
}
|
||||
}
|
||||
|
||||
poolsJSON, err := json.Marshal(s.PointsPools)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation pools: %w", err)
|
||||
}
|
||||
|
||||
rewardJSON, err := json.Marshal(s.PointsReward)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
|
||||
}
|
||||
|
||||
if s.NowPaymentsCurrencies == nil {
|
||||
s.NowPaymentsCurrencies = []string{}
|
||||
}
|
||||
currenciesJSON, err := json.Marshal(s.NowPaymentsCurrencies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation nowpayments_currencies: %w", err)
|
||||
}
|
||||
|
||||
schedJSON, err := json.Marshal(s.DeliverySchedule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation delivery_schedule: %w", err)
|
||||
}
|
||||
|
||||
if s.PostalZones == nil {
|
||||
s.PostalZones = []models.PostalZone{}
|
||||
}
|
||||
zonesJSON, err := json.Marshal(s.PostalZones)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation postal_zones: %w", err)
|
||||
}
|
||||
|
||||
if s.DeliveryMode.CategoryRoutes == nil {
|
||||
s.DeliveryMode.CategoryRoutes = []models.CategoryRoute{}
|
||||
}
|
||||
deliveryModeJSON, err := json.Marshal(s.DeliveryMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
|
||||
}
|
||||
|
||||
if s.ContactTelegram == "" {
|
||||
s.ContactTelegram = "MLN44LA"
|
||||
}
|
||||
pairs := [][2]string{
|
||||
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
|
||||
{"show_amende_score", boolStr(s.ShowAmendeScore)},
|
||||
{"points_enabled", boolStr(s.PointsEnabled)},
|
||||
{"points_pools", string(poolsJSON)},
|
||||
{"points_reward", string(rewardJSON)},
|
||||
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
||||
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
||||
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
||||
{"crypto_only", boolStr(s.CryptoOnly)},
|
||||
{"nowpayments_api_key", s.NowPaymentsAPIKey},
|
||||
{"nowpayments_ipn_secret", s.NowPaymentsIPNSecret},
|
||||
{"nowpayments_currencies", string(currenciesJSON)},
|
||||
{"delivery_schedule", string(schedJSON)},
|
||||
{"postal_zones", string(zonesJSON)},
|
||||
{"telegram_bot_token", s.TelegramBotToken},
|
||||
{"telegram_bot_username", s.TelegramBotUsername},
|
||||
{"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)},
|
||||
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
|
||||
{"delivery_mode", string(deliveryModeJSON)},
|
||||
{"shop_name", s.ShopName},
|
||||
{"contact_telegram", s.ContactTelegram},
|
||||
{"admin_color_primary", s.AdminColorPrimary},
|
||||
{"admin_color_secondary", s.AdminColorSecondary},
|
||||
{"admin_color_success", s.AdminColorSuccess},
|
||||
{"admin_color_danger", s.AdminColorDanger},
|
||||
{"admin_color_warning", s.AdminColorWarning},
|
||||
{"client_color_primary", s.ClientColorPrimary},
|
||||
{"client_color_secondary", s.ClientColorSecondary},
|
||||
{"client_color_success", s.ClientColorSuccess},
|
||||
{"client_color_danger", s.ClientColorDanger},
|
||||
{"client_color_warning", s.ClientColorWarning},
|
||||
{"client_title_gradient_from", s.ClientTitleGradientFrom},
|
||||
{"client_title_gradient_to", s.ClientTitleGradientTo},
|
||||
}
|
||||
|
||||
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, p := range pairs {
|
||||
if err := tx.Exec(upsert, p[0], p[1]).Error; err != nil {
|
||||
return fmt.Errorf("erreur upsert %s: %w", p[0], err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Reset des sections de stats ─────────────────────────────────────────────
|
||||
|
||||
// ResetAdminStat enregistre (ou met à jour) la date de reset pour une section.
|
||||
func (d *Database) ResetAdminStat(section string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
|
||||
return d.GDB.Exec(upsert, section, now).Error
|
||||
}
|
||||
|
||||
// ReadResetAt lit la date de reset stockée pour une clé donnée (zero value si absente).
|
||||
func (d *Database) ReadResetAt(key string) time.Time {
|
||||
var row struct {
|
||||
Value string
|
||||
}
|
||||
err := d.GDB.Table("app_settings").
|
||||
Select("value").
|
||||
Where("key = ?", key).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
if row.Value != "" {
|
||||
if t, err := time.Parse(time.RFC3339, row.Value); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// ── Construction des clauses WHERE (filtrage par reset) ────────────────────
|
||||
|
||||
// statusFilterClause construit "<baseStatus> [AND <dateColumn> >= ?]" et
|
||||
// renvoie la clause ainsi que les arguments à binder, dans l'ordre.
|
||||
// dateColumn doit être qualifié par l'alias de table (ex: "c.created_at") dès
|
||||
// que la requête appelante fait une jointure où plusieurs tables possèdent une
|
||||
// colonne created_at, sous peine d'erreur Postgres "ambiguous column".
|
||||
func statusFilterClause(baseStatus string, resetAt time.Time, dateColumn string) (string, []interface{}) {
|
||||
if !resetAt.IsZero() {
|
||||
return baseStatus + " AND " + dateColumn + " >= ?", []interface{}{resetAt.Format(time.RFC3339)}
|
||||
}
|
||||
return baseStatus, nil
|
||||
}
|
||||
|
||||
// AdminStatsFilters regroupe les dates de reset pour chaque section, lues une
|
||||
// seule fois puis transmises aux différentes requêtes.
|
||||
type AdminStatsFilters struct {
|
||||
ResetCommandes time.Time
|
||||
ResetRevenus time.Time
|
||||
ResetProduits time.Time
|
||||
ResetHeures time.Time
|
||||
ResetJours time.Time
|
||||
ResetDoses time.Time
|
||||
}
|
||||
|
||||
// LoadAdminStatsFilters lit toutes les dates de reset en une seule requête.
|
||||
func (d *Database) LoadAdminStatsFilters() AdminStatsFilters {
|
||||
keys := []string{
|
||||
"stats_reset_commandes_at",
|
||||
"stats_reset_revenus_at",
|
||||
"stats_reset_produits_at",
|
||||
"stats_reset_heures_at",
|
||||
"stats_reset_jours_at",
|
||||
"stats_reset_doses_at",
|
||||
}
|
||||
var rows []struct {
|
||||
Key string `gorm:"column:key"`
|
||||
Value string `gorm:"column:value"`
|
||||
}
|
||||
d.GDB.Table("app_settings").Select("key, value").Where("key IN ?", keys).Scan(&rows)
|
||||
|
||||
m := make(map[string]time.Time, len(keys))
|
||||
for _, r := range rows {
|
||||
if t, err := time.Parse(time.RFC3339, r.Value); err == nil {
|
||||
m[r.Key] = t
|
||||
}
|
||||
}
|
||||
return AdminStatsFilters{
|
||||
ResetCommandes: m["stats_reset_commandes_at"],
|
||||
ResetRevenus: m["stats_reset_revenus_at"],
|
||||
ResetProduits: m["stats_reset_produits_at"],
|
||||
ResetHeures: m["stats_reset_heures_at"],
|
||||
ResetJours: m["stats_reset_jours_at"],
|
||||
ResetDoses: m["stats_reset_doses_at"],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commandes par jour de la semaine (non annulées) ─────────────────────────
|
||||
|
||||
func (d *Database) OrderPerDaysPerWeeks(wdRows *[]models.WeekdayRow, resetAt time.Time) error {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
query := `
|
||||
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
|
||||
FROM commandes
|
||||
WHERE ` + where + `
|
||||
GROUP BY dow
|
||||
ORDER BY dow
|
||||
`
|
||||
return d.GDB.Raw(query, args...).Scan(wdRows).Error
|
||||
}
|
||||
|
||||
// ── Commandes par jour sur 30 jours ──────────────────────────────────────────
|
||||
|
||||
func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time) error {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
query := `
|
||||
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
AND ` + where + `
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day
|
||||
`
|
||||
return d.GDB.Raw(query, args...).Scan(dayRows).Error
|
||||
}
|
||||
|
||||
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────────
|
||||
|
||||
func (d *Database) RevenueByDayLast30(dayRevRows *[]models.DayRevenueRow, resetAt time.Time) error {
|
||||
where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
|
||||
query := `
|
||||
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
AND ` + where + `
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day
|
||||
`
|
||||
return d.GDB.Raw(query, args...).Scan(dayRevRows).Error
|
||||
}
|
||||
|
||||
// ── Commandes par jour sur un mois calendaire complet ────────────────────────
|
||||
|
||||
type DailyMonthStatRow struct {
|
||||
Day time.Time
|
||||
Count int
|
||||
Revenue float64
|
||||
Quantity float64
|
||||
}
|
||||
|
||||
// StatsByDayForMonth applique resetCommandes au comptage (count) et à la
|
||||
// quantité (quantity, qui reflète le volume de commandes comme count), et
|
||||
// resetRevenus au revenu (revenue) — chaque métrique doit respecter la même
|
||||
// section de reset que son équivalent dans le résumé global (TotalOrders /
|
||||
// TotalRevenue), sous peine d'afficher des chiffres incohérents entre eux
|
||||
// après une réinitialisation partielle.
|
||||
func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetCommandes time.Time, resetRevenus time.Time) error {
|
||||
start := time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
|
||||
end := start.AddDate(0, 1, 0)
|
||||
|
||||
whereCount, argsCount := statusFilterClause("status != 'cancelled'", resetCommandes, "created_at")
|
||||
whereRevenue, argsRevenue := statusFilterClause("status = 'approved'", resetRevenus, "created_at")
|
||||
whereQuantity, argsQuantity := statusFilterClause("c.status != 'cancelled'", resetCommandes, "c.created_at")
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
d.day,
|
||||
COALESCE(d.count, 0) AS count,
|
||||
COALESCE(rv.revenue, 0) AS revenue,
|
||||
COALESCE(qt.quantity, 0) AS quantity
|
||||
FROM (
|
||||
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
||||
FROM commandes
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND ` + whereCount + `
|
||||
GROUP BY DATE(created_at)
|
||||
) d
|
||||
LEFT JOIN (
|
||||
SELECT DATE(created_at) AS day,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND ` + whereRevenue + `
|
||||
GROUP BY DATE(created_at)
|
||||
) rv ON rv.day = d.day
|
||||
LEFT JOIN (
|
||||
SELECT DATE(c.created_at) AS day, SUM(ci.quantite) AS quantity
|
||||
FROM commandes c
|
||||
JOIN command_items ci ON ci.command_id = c.id
|
||||
WHERE c.created_at >= ? AND c.created_at < ?
|
||||
AND ` + whereQuantity + `
|
||||
GROUP BY DATE(c.created_at)
|
||||
) qt ON qt.day = d.day
|
||||
ORDER BY d.day
|
||||
`
|
||||
|
||||
// Ordre des "?" dans la requête : (start, end, [resetCommandes]) pour "d",
|
||||
// puis (start, end, [resetRevenus]) pour "rv", puis (start, end, [resetCommandes]) pour "qt".
|
||||
args := []interface{}{start, end}
|
||||
args = append(args, argsCount...)
|
||||
args = append(args, start, end)
|
||||
args = append(args, argsRevenue...)
|
||||
args = append(args, start, end)
|
||||
args = append(args, argsQuantity...)
|
||||
|
||||
return d.GDB.Raw(query, args...).Scan(rows).Error
|
||||
}
|
||||
|
||||
// OrdersAndRevenueByHour renvoie, par heure, le nombre de commandes non annulées
|
||||
// (volume d'activité) et le revenu confirmé (commandes approuvées uniquement —
|
||||
// cohérent avec TotalRevenue/RevenueByDayLast30, pour ne pas compter comme
|
||||
// "revenu" une commande encore en cours qui pourrait être annulée).
|
||||
func (d *Database) OrdersAndRevenueByHour(hourRows *[]models.HourRow, resetAt time.Time) error {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
query := `
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM created_at)::int AS hour,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(CASE WHEN status = 'approved' THEN total_prix - COALESCE(referral_used, 0) ELSE 0 END), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE ` + where + `
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`
|
||||
return d.GDB.Raw(query, args...).Scan(hourRows).Error
|
||||
}
|
||||
|
||||
// ── Top produits (quantité vendue) ───────────────────────────────────────────
|
||||
|
||||
// TopProducts renvoie les produits les plus commandés. La quantité/le nombre de
|
||||
// commandes reflètent l'activité (non annulées), le revenu ne compte que les
|
||||
// commandes approuvées (revenu confirmé, cohérent avec le résumé global).
|
||||
func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time, limit int) error {
|
||||
where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
|
||||
args = append(args, limit)
|
||||
query := `
|
||||
SELECT
|
||||
ci.product_id,
|
||||
ci.produit AS name,
|
||||
SUM(ci.quantite) AS total_quantity,
|
||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||
SUM(CASE WHEN c.status = 'approved'
|
||||
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
|
||||
ELSE 0 END) AS revenue,
|
||||
COALESCE(p.category, '') AS category,
|
||||
COALESCE(cat.color, '#7c3aed') AS category_color
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
LEFT JOIN products p ON p.id = ci.product_id
|
||||
LEFT JOIN categories cat ON cat.name = p.category
|
||||
WHERE ` + where + `
|
||||
GROUP BY ci.product_id, ci.produit, p.category, cat.color
|
||||
ORDER BY total_quantity DESC
|
||||
LIMIT ?
|
||||
`
|
||||
return d.GDB.Raw(query, args...).Scan(prodRows).Error
|
||||
}
|
||||
|
||||
// ── Répartition des doses/quantités par produit ──────────────────────────────
|
||||
|
||||
// QuantityBreakdown : quantité/nombre de commandes reflètent l'activité (non
|
||||
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
|
||||
func (d *Database) QuantityBreakdown(qtyRows *[]models.QuantityBreakdownRow, resetAt time.Time) error {
|
||||
where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
|
||||
query := `
|
||||
SELECT
|
||||
ci.product_id,
|
||||
ci.produit AS product_name,
|
||||
ci.quantite AS quantity,
|
||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||
SUM(ci.quantite) AS total_sold,
|
||||
SUM(CASE WHEN c.status = 'approved'
|
||||
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
|
||||
ELSE 0 END) AS revenue,
|
||||
COALESCE(cat.color, '#7c3aed') AS category_color
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
LEFT JOIN products p ON p.id = ci.product_id
|
||||
LEFT JOIN categories cat ON cat.name = p.category
|
||||
WHERE ` + where + `
|
||||
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
|
||||
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
|
||||
`
|
||||
return d.GDB.Raw(query, args...).Scan(qtyRows).Error
|
||||
}
|
||||
|
||||
// ── Détail du jour (catégorie → produits) ────────────────────────────────────
|
||||
|
||||
// DailyProductDetail : quantité/nombre de commandes reflètent l'activité (non
|
||||
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
|
||||
func (d *Database) DailyProductDetail(dailyRows *[]models.DailyProductRow) error {
|
||||
query := `
|
||||
SELECT
|
||||
ci.product_id,
|
||||
ci.produit AS product_name,
|
||||
COALESCE(p.category, 'Sans catégorie') AS category,
|
||||
COALESCE(cat.color, '#7c3aed') AS category_color,
|
||||
SUM(ci.quantite) AS total_quantity,
|
||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||
SUM(CASE WHEN c.status = 'approved'
|
||||
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
|
||||
ELSE 0 END) AS revenue
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
LEFT JOIN products p ON p.id = ci.product_id
|
||||
LEFT JOIN categories cat ON cat.name = p.category
|
||||
WHERE DATE(c.created_at) = CURRENT_DATE
|
||||
AND c.status != 'cancelled'
|
||||
GROUP BY ci.product_id, ci.produit, p.category, cat.color
|
||||
ORDER BY p.category, SUM(ci.quantite) DESC
|
||||
`
|
||||
return d.GDB.Raw(query).Scan(dailyRows).Error
|
||||
}
|
||||
|
||||
func (d *Database) DailyProductDetailForDate(dailyRows *[]models.DailyProductRow, date time.Time) error {
|
||||
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
end := start.AddDate(0, 0, 1)
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
ci.product_id,
|
||||
ci.produit AS product_name,
|
||||
COALESCE(p.category, 'Sans catégorie') AS category,
|
||||
COALESCE(cat.color, '#7c3aed') AS category_color,
|
||||
SUM(ci.quantite) AS total_quantity,
|
||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||
SUM(CASE WHEN c.status = 'approved'
|
||||
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
|
||||
ELSE 0 END) AS revenue
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
LEFT JOIN products p ON p.id = ci.product_id
|
||||
LEFT JOIN categories cat ON cat.name = p.category
|
||||
WHERE c.created_at >= ? AND c.created_at < ?
|
||||
AND c.status != 'cancelled'
|
||||
GROUP BY ci.product_id, ci.produit, p.category, cat.color
|
||||
ORDER BY p.category, SUM(ci.quantite) DESC
|
||||
`
|
||||
return d.GDB.Raw(query, start, end).Scan(dailyRows).Error
|
||||
}
|
||||
|
||||
// DailyOrdersCountForDate renvoie le nombre de commandes (non annulées) pour
|
||||
// une date précise.
|
||||
func (d *Database) DailyOrdersCountForDate(date time.Time) (int64, error) {
|
||||
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
end := start.AddDate(0, 0, 1)
|
||||
|
||||
var count int64
|
||||
err := d.GDB.Raw(`
|
||||
SELECT COUNT(DISTINCT id) FROM commandes
|
||||
WHERE created_at >= ? AND created_at < ? AND status != 'cancelled'
|
||||
`, start, end).Scan(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// DailyOrdersCount renvoie le nombre de commandes (non annulées) du jour.
|
||||
func (d *Database) DailyOrdersCount() (int64, error) {
|
||||
var count int64
|
||||
err := d.GDB.Raw(`
|
||||
SELECT COUNT(DISTINCT id) FROM commandes
|
||||
WHERE DATE(created_at) = CURRENT_DATE AND status != 'cancelled'
|
||||
`).Scan(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ── Résumé global ────────────────────────────────────────────────────────────
|
||||
|
||||
// TotalOrders renvoie le nombre total de commandes filtré par le reset "commandes".
|
||||
func (d *Database) TotalOrders(resetAt time.Time) (int64, error) {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
var total int64
|
||||
err := d.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE `+where, args...).Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// TotalRevenue renvoie le revenu total (commandes approuvées) filtré par le reset "revenus".
|
||||
func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
|
||||
where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
|
||||
var total float64
|
||||
err := d.GDB.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE `+where, args...).
|
||||
Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// ActiveDaysLast30 renvoie le nombre de jours distincts ayant eu au moins une commande sur 30 jours.
|
||||
func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
var activeDays int64
|
||||
query := `
|
||||
SELECT COUNT(DISTINCT DATE(created_at))
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where
|
||||
err := d.GDB.Raw(query, args...).Scan(&activeDays).Error
|
||||
return activeDays, err
|
||||
}
|
||||
|
||||
// OrdersCountLast30 renvoie le nombre de commandes sur les 30 derniers jours.
|
||||
func (d *Database) OrdersCountLast30(resetAt time.Time) (int64, error) {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
var count int64
|
||||
query := `
|
||||
SELECT COUNT(*) FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where
|
||||
err := d.GDB.Raw(query, args...).Scan(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (d *Database) GetMyDeliveryStatsPerDay(statsRows *[]models.DayRowWithResult, username string) error {
|
||||
query := `
|
||||
SELECT DATE(updated_at) AS day,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?
|
||||
AND status IN ('livre', 'approved')
|
||||
AND updated_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY DATE(updated_at)
|
||||
ORDER BY day
|
||||
`
|
||||
return d.GDB.Raw(query, username).Scan(statsRows).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetMyDeliveryStatsPerWeek(statsRow *[]models.WeekRow, username string) error {
|
||||
query := `
|
||||
SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num,
|
||||
EXTRACT(YEAR FROM updated_at)::int AS year,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?
|
||||
AND status IN ('livre', 'approved')
|
||||
AND updated_at >= NOW() - INTERVAL '12 weeks'
|
||||
GROUP BY week_num, year
|
||||
ORDER BY year, week_num
|
||||
`
|
||||
return d.GDB.Raw(query, username).Scan(statsRow).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetMyDeliveryStatsPerMonth(statsRow *[]models.MonthRow, username string) error {
|
||||
query := `
|
||||
SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num,
|
||||
EXTRACT(YEAR FROM updated_at)::int AS year,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?
|
||||
AND status IN ('livre', 'approved')
|
||||
AND updated_at >= NOW() - INTERVAL '12 months'
|
||||
GROUP BY month_num, year
|
||||
ORDER BY year, month_num
|
||||
`
|
||||
return d.GDB.Raw(query, username).Scan(statsRow).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetMyDeliveryStatsToday(statsRow *models.TodayRow, username string) error {
|
||||
query := `
|
||||
SELECT COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?
|
||||
AND status IN ('livre', 'approved')
|
||||
AND DATE(updated_at) = CURRENT_DATE
|
||||
`
|
||||
return d.GDB.Raw(query, username).Scan(statsRow).Error
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MigrateAddTelegramColumns ajoute les colonnes telegram_chat_id si elles n'existent pas
|
||||
func (d *Database) MigrateAddTelegramColumns() {
|
||||
migrations := []string{
|
||||
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
|
||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
|
||||
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS two_fa_enabled BOOLEAN NOT NULL DEFAULT FALSE`,
|
||||
}
|
||||
for _, q := range migrations {
|
||||
if err := d.GDB.Exec(q).Error; err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM_MIGRATION] %v", err)
|
||||
}
|
||||
}
|
||||
log.Println("✅ [TELEGRAM] Colonnes telegram_chat_id vérifiées")
|
||||
}
|
||||
|
||||
const linkTokenTTL = 10 * time.Minute
|
||||
|
||||
// GenerateLinkToken crée un token aléatoire sécurisé et le stocke dans Redis (10 min)
|
||||
func GenerateLinkToken(username, role string) (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("génération token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
|
||||
data := models.TelegramLinkData{Username: username, Role: role}
|
||||
val, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("telegram:link:%s", token)
|
||||
if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil {
|
||||
return "", fmt.Errorf("redis set: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ValidateAndConsumeLinkToken valide le token, retourne les données, puis le supprime
|
||||
func ValidateAndConsumeLinkToken(token string) (username, role string, err error) {
|
||||
key := fmt.Sprintf("telegram:link:%s", token)
|
||||
|
||||
val, err := Redis.Get(RedisCtx, key).Bytes()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("token invalide ou expiré")
|
||||
}
|
||||
|
||||
var data models.TelegramLinkData
|
||||
if err := json.Unmarshal(val, &data); err != nil {
|
||||
return "", "", fmt.Errorf("données corrompues")
|
||||
}
|
||||
|
||||
Redis.Del(RedisCtx, key)
|
||||
|
||||
return data.Username, data.Role, nil
|
||||
}
|
||||
|
||||
func (d *Database) SaveClientTelegramChatID(username string, chatID int64) error {
|
||||
return d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("telegram_chat_id", chatID).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetClientTelegramChatID(username string) (int64, bool, error) {
|
||||
var result struct {
|
||||
ChatID *int64 `gorm:"column:telegram_chat_id"`
|
||||
}
|
||||
if err := d.GDB.Table("clients").Select("telegram_chat_id").Where("username = ?", username).Limit(1).Scan(&result).Error; err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if result.ChatID == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
return *result.ChatID, true, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteClientTelegramChatID(username string) error {
|
||||
return d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
|
||||
}
|
||||
|
||||
func (d *Database) SaveUserTelegramChatID(username string, chatID int64) error {
|
||||
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", chatID).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetUserTelegramChatID(username string) (int64, bool, error) {
|
||||
var result struct {
|
||||
ChatID *int64 `gorm:"column:telegram_chat_id"`
|
||||
}
|
||||
if err := d.GDB.Table("users").Select("telegram_chat_id").Where("username = ?", username).Limit(1).Scan(&result).Error; err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if result.ChatID == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
return *result.ChatID, true, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteUserTelegramChatID(username string) error {
|
||||
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
|
||||
}
|
||||
|
||||
// GetAllLinkedTelegramAccounts retourne tous les comptes ayant un telegram_chat_id non-null.
|
||||
func (d *Database) GetAllLinkedTelegramAccounts() ([]struct {
|
||||
ChatID int64
|
||||
Username string
|
||||
Role string
|
||||
}, error) {
|
||||
type row struct {
|
||||
ChatID int64 `gorm:"column:telegram_chat_id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Role string `gorm:"column:role"`
|
||||
}
|
||||
|
||||
var results []row
|
||||
|
||||
var clients []row
|
||||
if err := d.GDB.Raw(`SELECT telegram_chat_id, username, 'client' AS role FROM clients WHERE telegram_chat_id IS NOT NULL`).Scan(&clients).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, clients...)
|
||||
|
||||
var users []row
|
||||
if err := d.GDB.Raw(`SELECT telegram_chat_id, username, role FROM users WHERE telegram_chat_id IS NOT NULL`).Scan(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, users...)
|
||||
|
||||
out := make([]struct {
|
||||
ChatID int64
|
||||
Username string
|
||||
Role string
|
||||
}, len(results))
|
||||
for i, r := range results {
|
||||
out[i].ChatID = r.ChatID
|
||||
out[i].Username = r.Username
|
||||
out[i].Role = r.Role
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetUserByTelegramChatID retrouve un utilisateur (clients + users) par chat_id
|
||||
func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) {
|
||||
var clientResult struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
if err = d.GDB.Table("clients").Select("username").Where("telegram_chat_id = ?", chatID).Limit(1).Scan(&clientResult).Error; err == nil && clientResult.Username != "" {
|
||||
return clientResult.Username, "client", nil
|
||||
}
|
||||
|
||||
var userResult struct {
|
||||
Username string `gorm:"column:username"`
|
||||
Role string `gorm:"column:role"`
|
||||
}
|
||||
if err = d.GDB.Model(&models.User{}).Select("username, role").Where("telegram_chat_id = ?", chatID).Limit(1).Scan(&userResult).Error; err == nil && userResult.Username != "" {
|
||||
return userResult.Username, userResult.Role, nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("aucun compte lié à ce chat_id")
|
||||
}
|
||||
|
||||
// ── 2FA sessions ─────────────────────────────────────────────────────────────
|
||||
|
||||
const twoFASessionTTL = 5 * time.Minute
|
||||
|
||||
type twoFASessionData struct {
|
||||
Username string `json:"username"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func Store2FASession(sessionToken, username, code string) error {
|
||||
data, err := json.Marshal(twoFASessionData{Username: username, Code: code})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return Redis.Set(RedisCtx, "2fa:session:"+sessionToken, data, twoFASessionTTL).Err()
|
||||
}
|
||||
|
||||
// Verify2FASession valide le code et retourne le username. GETDEL = atomique (anti-replay).
|
||||
func Verify2FASession(sessionToken, code string) (string, error) {
|
||||
val, err := Redis.GetDel(RedisCtx, "2fa:session:"+sessionToken).Bytes()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("session invalide ou expirée")
|
||||
}
|
||||
var d twoFASessionData
|
||||
if err := json.Unmarshal(val, &d); err != nil {
|
||||
return "", fmt.Errorf("données corrompues")
|
||||
}
|
||||
if d.Code != code {
|
||||
return "", fmt.Errorf("code incorrect")
|
||||
}
|
||||
return d.Username, nil
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (d *Database) CreateUser(user *models.User) error {
|
||||
if err := d.GDB.Create(user).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors de la création de l'utilisateur: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllUsers() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := d.GDB.Order("created_at DESC").Limit(500).Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := d.GDB.Where("role = ?", "livreur").Limit(100).Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateUser(user *models.User) error {
|
||||
result := d.GDB.Model(user).Updates(models.User{
|
||||
Username: user.Username,
|
||||
Password: user.Password,
|
||||
Role: user.Role,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de l'utilisateur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteUser(id int) error {
|
||||
var user models.User
|
||||
if err := d.GDB.First(&user, id).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return fmt.Errorf("erreur lors de la récupération du rôle: %w", err)
|
||||
}
|
||||
|
||||
_ = d.RevokeAllUserTokens(id, user.Role)
|
||||
|
||||
result := d.GDB.Delete(&models.User{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression de l'utilisateur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ Utilisateur supprimé (ID: %d, Role: %s)", id, user.Role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByID(id int) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := d.GDB.First(&user, id).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByUsername(username string) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := d.GDB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package db
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
Redis *redis.Client
|
||||
RedisCtx = context.Background()
|
||||
)
|
||||
|
||||
type CommandWithDistance struct {
|
||||
CommandID int
|
||||
Address string
|
||||
Lat float64
|
||||
Lng float64
|
||||
Distance float64
|
||||
EstimatedETA int
|
||||
QueueItem models.CommandQueue
|
||||
}
|
||||
|
||||
const (
|
||||
// Temps moyen estimé par livraison (en minutes)
|
||||
AVG_DELIVERY_TIME = 15
|
||||
// Nombre maximum de commandes par livreur
|
||||
MAX_COMMANDS_PER_DELIVERYMAN = 20
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// INITIALISATION DE REDIS
|
||||
// ============================================
|
||||
|
||||
func InitRedis() {
|
||||
host := getEnv("REDIS_HOST", "redis")
|
||||
port := getEnv("REDIS_PORT", "6379")
|
||||
password := os.Getenv("REDIS_PASSWORD")
|
||||
|
||||
addr := host + ":" + port
|
||||
|
||||
Redis = redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: 0,
|
||||
})
|
||||
|
||||
_, err := Redis.Ping(RedisCtx).Result()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur connexion Redis: %v", err)
|
||||
}
|
||||
|
||||
log.Println("⚡ Redis connecté")
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"math"
|
||||
)
|
||||
|
||||
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
|
||||
func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
|
||||
keys, err := scanRedisKeys("delivery:status:*")
|
||||
if err != nil || len(keys) == 0 {
|
||||
return "", fmt.Errorf("aucun livreur trouvé")
|
||||
}
|
||||
|
||||
var leastLoaded string
|
||||
minQueueSize := int64(MAX_COMMANDS_PER_DELIVERYMAN + 1)
|
||||
|
||||
for _, key := range keys {
|
||||
username := key[len("delivery:status:"):]
|
||||
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status == "offline" {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ NOUVEAU: Vérifier si le livreur peut accepter des commandes
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
log.Printf("⏭️ [LEAST_LOADED] Skip %s: ne peut pas accepter", username)
|
||||
continue
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// Priorité aux livreurs disponibles (bonus de -1000)
|
||||
effectiveQueueSize := queueSize
|
||||
if status.Status == "available" {
|
||||
effectiveQueueSize -= 1000
|
||||
}
|
||||
|
||||
if effectiveQueueSize < minQueueSize {
|
||||
minQueueSize = effectiveQueueSize
|
||||
leastLoaded = username
|
||||
}
|
||||
}
|
||||
|
||||
if leastLoaded == "" {
|
||||
return "", fmt.Errorf("aucun livreur disponible")
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", leastLoaded)
|
||||
actualQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
log.Printf("🎯 [LEAST_LOADED] %s sélectionné (%d/10 commandes)",
|
||||
leastLoaded, actualQueueSize)
|
||||
|
||||
return leastLoaded, nil
|
||||
}
|
||||
|
||||
// GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
|
||||
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
|
||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||
if err != nil || len(activeUsernames) == 0 {
|
||||
return "", 0, fmt.Errorf("aucun livreur actif")
|
||||
}
|
||||
|
||||
var leastLoaded string
|
||||
var minQueueSize int64 = math.MaxInt64
|
||||
|
||||
for _, username := range activeUsernames {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if queueSize < minQueueSize {
|
||||
minQueueSize = queueSize
|
||||
leastLoaded = username
|
||||
}
|
||||
}
|
||||
|
||||
return leastLoaded, minQueueSize, nil
|
||||
}
|
||||
|
||||
// AreAllDeliverymenAtCapacity vérifie si tous les livreurs ont atteint leur capacité max
|
||||
func (d *Database) AreAllDeliverymenAtCapacity() (bool, int, error) {
|
||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||
if err != nil || len(activeUsernames) == 0 {
|
||||
return false, 0, fmt.Errorf("aucun livreur actif")
|
||||
}
|
||||
|
||||
if len(activeUsernames) == 1 {
|
||||
return false, 1, nil
|
||||
}
|
||||
|
||||
atCapacityCount := 0
|
||||
for _, username := range activeUsernames {
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
atCapacityCount++
|
||||
}
|
||||
}
|
||||
|
||||
allAtCapacity := atCapacityCount == len(activeUsernames)
|
||||
|
||||
if allAtCapacity {
|
||||
log.Printf("🔴 [CAPACITY] TOUS les livreurs sont à capacité maximale (%d/%d)",
|
||||
atCapacityCount, len(activeUsernames))
|
||||
}
|
||||
|
||||
return allAtCapacity, len(activeUsernames), nil
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Database) UpdateDeliveryPersonLocation(username string, lat, lon float64) error {
|
||||
key := fmt.Sprintf("delivery:location:%s", username)
|
||||
location := map[string]any{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"last_update": time.Now().Unix(),
|
||||
}
|
||||
data, _ := json.Marshal(location)
|
||||
err := Redis.Set(RedisCtx, key, data, 1*time.Hour).Err()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour position: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📍 Position GPS mise à jour pour %s: (%.6f, %.6f)", username, lat, lon)
|
||||
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
||||
statusData, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
|
||||
if err != nil || statusData == "" {
|
||||
log.Printf("🆕 [INIT_STATUS] Création statut 'available' pour %s (première position GPS)", username)
|
||||
d.SetDeliveryPersonStatus(username, "available", 0)
|
||||
} else {
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(statusData), &status)
|
||||
|
||||
if status.Status == "offline" {
|
||||
log.Printf("🔄 [REACTIVATE] %s passe de 'offline' à 'available' (position GPS reçue)", username)
|
||||
d.SetDeliveryPersonStatus(username, "available", 0)
|
||||
} else {
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(username)
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ Publier l'événement de mise à jour de position
|
||||
d.PublishDeliveryPersonLocationUpdate(username, lat, lon)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonLocation récupère la position d'un livreur
|
||||
func (d *Database) GetDeliveryPersonLocation(username string) (float64, float64, error) {
|
||||
key := fmt.Sprintf("delivery:location:%s", username)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("position non trouvée pour %s: %w", username, err)
|
||||
}
|
||||
|
||||
if data == "" {
|
||||
return 0, 0, fmt.Errorf("aucune donnée de position pour %s", username)
|
||||
}
|
||||
|
||||
var location map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &location); err != nil {
|
||||
return 0, 0, fmt.Errorf("erreur parsing JSON Redis: %w", err)
|
||||
}
|
||||
|
||||
var lat, lon float64
|
||||
if v, ok := location["latitude"]; ok && v != nil {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
lat = val
|
||||
case float32:
|
||||
lat = float64(val)
|
||||
case int:
|
||||
lat = float64(val)
|
||||
case int64:
|
||||
lat = float64(val)
|
||||
case string:
|
||||
lat, _ = strconv.ParseFloat(val, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := location["longitude"]; ok && v != nil {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
lon = val
|
||||
case float32:
|
||||
lon = float64(val)
|
||||
case int:
|
||||
lon = float64(val)
|
||||
case int64:
|
||||
lon = float64(val)
|
||||
case string:
|
||||
lon, _ = strconv.ParseFloat(val, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if lat == 0 && lon == 0 {
|
||||
return 0, 0, fmt.Errorf("coordonnées invalides (0,0) pour %s", username)
|
||||
}
|
||||
|
||||
return lat, lon, nil
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/services"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func (d *Database) SetCommandETA(commandID, minutes int) error {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(minutes) * time.Minute)
|
||||
|
||||
eta := map[string]any{
|
||||
"command_id": commandID,
|
||||
"total_eta_minutes": minutes,
|
||||
"eta_minutes": minutes,
|
||||
"wait_time_minutes": 0,
|
||||
"travel_time_minutes": minutes,
|
||||
"queue_position": 1,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
_, err := Redis.HSet(RedisCtx, key, eta).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
|
||||
}
|
||||
|
||||
Redis.Expire(RedisCtx, key, 2*time.Hour)
|
||||
|
||||
d.ScheduleETANotifications(commandID, minutes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommandETA récupère l'ETA d'une commande depuis Redis
|
||||
func (d *Database) GetCommandETA(commandID int) (map[string]string, error) {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
etaData, err := Redis.HGetAll(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(etaData) == 0 {
|
||||
return nil, fmt.Errorf("aucun ETA trouvé pour la commande %d", commandID)
|
||||
}
|
||||
|
||||
return etaData, nil
|
||||
}
|
||||
|
||||
// CheckCommandETAExists vérifie si un ETA existe pour une commande
|
||||
func (d *Database) CheckCommandETAExists(commandID int) bool {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
exists, _ := Redis.Exists(RedisCtx, key).Result()
|
||||
return exists > 0
|
||||
}
|
||||
|
||||
// ScheduleETANotifications programme les notifications 5min et 3min
|
||||
func (d *Database) ScheduleETANotifications(commandID, etaMinutes int) error {
|
||||
arrivalTime := time.Now().Add(time.Duration(etaMinutes) * time.Minute)
|
||||
|
||||
if etaMinutes > 5 {
|
||||
notify5min := arrivalTime.Add(-5 * time.Minute).Unix()
|
||||
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
|
||||
Score: float64(notify5min),
|
||||
Member: fmt.Sprintf("%d:5min", commandID),
|
||||
})
|
||||
}
|
||||
|
||||
if etaMinutes > 3 {
|
||||
notify3min := arrivalTime.Add(-3 * time.Minute).Unix()
|
||||
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
|
||||
Score: float64(notify3min),
|
||||
Member: fmt.Sprintf("%d:3min", commandID),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessScheduledNotifications traite les notifications à envoyer
|
||||
func (d *Database) ProcessScheduledNotifications() error {
|
||||
now := float64(time.Now().Unix())
|
||||
|
||||
results, err := Redis.ZRangeByScore(RedisCtx, "notifications:scheduled", &redis.ZRangeBy{
|
||||
Min: "0",
|
||||
Max: strconv.FormatFloat(now, 'f', 0, 64),
|
||||
}).Result()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
parts := splitNotificationKey(result)
|
||||
commandID, _ := strconv.Atoi(parts[0])
|
||||
notifType := parts[1]
|
||||
|
||||
d.SendETANotification(commandID, notifType)
|
||||
Redis.ZRem(RedisCtx, "notifications:scheduled", result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendETANotification envoie une notification ETA
|
||||
func (d *Database) SendETANotification(commandID int, notifType string) {
|
||||
message := fmt.Sprintf("Votre commande #%d arrive dans %s", d.GetClientOrderID(commandID), notifType)
|
||||
|
||||
channel := fmt.Sprintf("notifications:command:%d", commandID)
|
||||
Redis.Publish(RedisCtx, channel, message)
|
||||
}
|
||||
|
||||
// CalculateETAForDeliveryman calcule l'ETA entre un livreur et une destination
|
||||
func (d *Database) CalculateETAForDeliveryman(deliveryman string, destLat, destLng float64) int {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
return services.MinETA
|
||||
}
|
||||
|
||||
from := services.Coordinates{
|
||||
Latitude: livreurLat,
|
||||
Longitude: livreurLng,
|
||||
}
|
||||
to := services.Coordinates{
|
||||
Latitude: destLat,
|
||||
Longitude: destLng,
|
||||
}
|
||||
|
||||
eta, _, err2 := services.CalculateETAWithTomTom(from, to)
|
||||
if err2 != nil {
|
||||
distance := services.CalculateDistance(from, to)
|
||||
eta = services.CalculateETA(distance)
|
||||
}
|
||||
|
||||
return eta
|
||||
}
|
||||
|
||||
// CalculateDistanceBetweenPoints calcule la distance entre deux points
|
||||
func (d *Database) CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2 float64) float64 {
|
||||
from := services.Coordinates{Latitude: lat1, Longitude: lng1}
|
||||
to := services.Coordinates{Latitude: lat2, Longitude: lng2}
|
||||
return services.CalculateDistance(from, to)
|
||||
}
|
||||
|
||||
// CalculateETABetweenPoints calcule l'ETA entre deux points
|
||||
func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int {
|
||||
distance := d.CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2)
|
||||
return services.CalculateETA(distance)
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(totalETA) * time.Minute)
|
||||
|
||||
eta := map[string]any{
|
||||
"command_id": commandID,
|
||||
"total_eta_minutes": totalETA,
|
||||
"eta_minutes": totalETA,
|
||||
"queue_position": queuePosition,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
_, err := Redis.HSet(RedisCtx, key, eta).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
|
||||
}
|
||||
|
||||
Redis.Expire(RedisCtx, key, 4*time.Hour)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package db
|
||||
|
||||
import "fmt"
|
||||
|
||||
func extractCommandID(member any) int {
|
||||
switch v := member.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
var id int
|
||||
fmt.Sscanf(v, "%d", &id)
|
||||
return id
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func splitNotificationKey(key string) []string {
|
||||
for i, c := range key {
|
||||
if c == ':' {
|
||||
return []string{key[:i], key[i+1:]}
|
||||
}
|
||||
}
|
||||
return []string{key, ""}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// db/redis_position.go
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// GESTION DES POSITIONS GPS
|
||||
// ============================================
|
||||
// ============================================
|
||||
// LEGACY: UpdateLivreurPosition (compatibilité)
|
||||
// ============================================
|
||||
|
||||
// UpdateLivreurPosition met à jour la position GPS ET le statut du livreur (LEGACY)
|
||||
func (d *Database) UpdateLivreurPosition(authUsername string, lat, lon float64, status string) error {
|
||||
// 🔹 1️⃣ Validation du username côté serveur
|
||||
if authUsername == "" {
|
||||
return fmt.Errorf("username non fourni ou non authentifié")
|
||||
}
|
||||
|
||||
// 🔹 2️⃣ Validation des coordonnées GPS
|
||||
if !isValidCoordinates(lat, lon) {
|
||||
return fmt.Errorf("coordonnées GPS invalides")
|
||||
}
|
||||
|
||||
// 🔹 3️⃣ Mise à jour position GPS (Redis)
|
||||
positionKey := fmt.Sprintf("livreur:position:%s", authUsername)
|
||||
position := models.LivreurPosition{
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
UpdatedAt: time.Now(),
|
||||
Status: status,
|
||||
}
|
||||
|
||||
positionData, err := json.Marshal(position)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation position: %w", err)
|
||||
}
|
||||
|
||||
if err := Redis.Set(RedisCtx, positionKey, positionData, 2*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur mise à jour position: %w", err)
|
||||
}
|
||||
|
||||
// 🔹 4️⃣ Mise à jour statut sécurisé
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", authUsername)
|
||||
var deliveryStatus models.DeliveryPersonStatus
|
||||
|
||||
existingData, _ := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if existingData != "" {
|
||||
json.Unmarshal([]byte(existingData), &deliveryStatus)
|
||||
deliveryStatus.Status = status
|
||||
deliveryStatus.LastUpdate = time.Now()
|
||||
} else {
|
||||
deliveryStatus = models.DeliveryPersonStatus{
|
||||
Username: authUsername,
|
||||
Status: status,
|
||||
CurrentCommand: 0,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
statusData, err := json.Marshal(deliveryStatus)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation statut: %w", err)
|
||||
}
|
||||
|
||||
if err := Redis.Set(RedisCtx, statusKey, statusData, 24*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
|
||||
// 🔹 6️⃣ Publication événement sécurisée (à sécuriser côté subscriber)
|
||||
d.PublishDeliveryPersonLocationUpdate(authUsername, lat, lon)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLivreurPosition récupère la position d'un livreur depuis Redis (LEGACY)
|
||||
func (d *Database) GetLivreurPosition(authUsername string) (*models.LivreurPosition, error) {
|
||||
if authUsername == "" {
|
||||
return nil, fmt.Errorf("username non fourni ou non authentifié")
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("livreur:position:%s", authUsername)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("position non trouvée: %w", err)
|
||||
}
|
||||
|
||||
var position models.LivreurPosition
|
||||
if err := json.Unmarshal([]byte(data), &position); err != nil {
|
||||
return nil, fmt.Errorf("erreur parsing position: %w", err)
|
||||
}
|
||||
|
||||
return &position, nil
|
||||
}
|
||||
|
||||
// Vérifie si la latitude et longitude sont valides
|
||||
func isValidCoordinates(lat, lon float64) bool {
|
||||
return !math.IsNaN(lat) && !math.IsNaN(lon) &&
|
||||
lat >= -90 && lat <= 90 &&
|
||||
lon >= -180 && lon <= 180
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PublishCommandEvent publie un événement de commande
|
||||
func (d *Database) PublishCommandEvent(commandID int, eventType, message string) {
|
||||
channel := fmt.Sprintf("events:command:%d", commandID)
|
||||
|
||||
event := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": eventType,
|
||||
"message": message,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(event)
|
||||
Redis.Publish(RedisCtx, channel, data)
|
||||
}
|
||||
|
||||
// PublishDeliveryPersonLocationUpdate publie un événement de mise à jour de position
|
||||
func (d *Database) PublishDeliveryPersonLocationUpdate(username string, lat, lon float64) {
|
||||
message := map[string]any{
|
||||
"type": "location_update",
|
||||
"username": username,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(message)
|
||||
Redis.Publish(RedisCtx, "delivery:events", data)
|
||||
|
||||
log.Printf("📡 Événement publié: Position de %s mise à jour", username)
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AssignCommandToDeliverymanQueue assigne une commande à la queue d'un livreur
|
||||
func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman string, estimatedTravelTime int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// Si plusieurs livreurs, appliquer la limite de 10
|
||||
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
}
|
||||
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
limitInfo := ""
|
||||
if activeCount > 1 {
|
||||
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
} else {
|
||||
limitInfo = " (sans limite - seul livreur)"
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "queued",
|
||||
fmt.Sprintf("Ajouté à la queue de %s (position: %d%s, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, limitInfo, totalETA),
|
||||
"system")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignCommandToDeliverymanQueueWithCoords assigne une commande avec les coordonnées GPS
|
||||
func (d *Database) AssignCommandToDeliverymanQueueWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
}
|
||||
|
||||
// ✅ ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var username string
|
||||
if u, ok := command["username"].(string); ok {
|
||||
username = u
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: username,
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
updateQuery := `UPDATE commandes
|
||||
SET livreur_assign = $1,
|
||||
status = 'assigned',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`
|
||||
|
||||
_, err = d.Exec(updateQuery, deliveryman, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur mise à jour livreur_assign: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour DB: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ DB mise à jour: livreur_assign=%s pour commande %d", deliveryman, commandID)
|
||||
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
// Sauvegarder dans le cache destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": lat,
|
||||
"lon": lng,
|
||||
})
|
||||
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
|
||||
|
||||
limitInfo := ""
|
||||
if activeCount > 1 {
|
||||
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
} else {
|
||||
limitInfo = " (sans limite - seul livreur)"
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "assigned",
|
||||
fmt.Sprintf("Assigné à %s (position: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d -> Livreur %s (pos: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
commandID, deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceAssignCommandToDeliverymanWithCoords assigne une commande de force avec coordonnées
|
||||
func (d *Database) ForceAssignCommandToDeliverymanWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var username string
|
||||
if u, ok := command["username"].(string); ok {
|
||||
username = u
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: username,
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
// Sauvegarder dans le cache destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": lat,
|
||||
"lon": lng,
|
||||
})
|
||||
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
|
||||
|
||||
d.AddCommandLog(commandID, "force_queued",
|
||||
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
deliveryman, currentQueueSize+1, totalETA, lat, lng),
|
||||
"system")
|
||||
|
||||
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
commandID, deliveryman, currentQueueSize+1, totalETA, lat, lng)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceAssignCommandToDeliveryman assigne une commande à un livreur SANS vérifier la limite de 10
|
||||
func (d *Database) ForceAssignCommandToDeliveryman(commandID int, deliveryman string, estimatedTravelTime int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
d.AddCommandLog(commandID, "force_queued",
|
||||
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, totalETA),
|
||||
"system")
|
||||
|
||||
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min)",
|
||||
commandID, deliveryman, currentQueueSize+1, totalETA)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoAssignCommand assigne automatiquement une commande au premier livreur disponible
|
||||
func (d *Database) AutoAssignCommand(commandID int) error {
|
||||
// Récupérer les livreurs disponibles
|
||||
available, err := d.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil || len(available) == 0 {
|
||||
return fmt.Errorf("aucun livreur disponible")
|
||||
}
|
||||
|
||||
// Prendre le premier livreur
|
||||
livreur := available[0]
|
||||
|
||||
// Assigner dans la DB principale
|
||||
err = d.AssignDeliveryPerson(commandID, livreur.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mettre à jour le statut dans Redis
|
||||
d.SetDeliveryPersonStatus(livreur.Username, "busy", commandID)
|
||||
|
||||
// Retirer de la file d'attente
|
||||
d.RemoveCommandFromQueue(commandID)
|
||||
|
||||
// Définir l'ETA initial
|
||||
d.SetCommandETA(commandID, 30)
|
||||
|
||||
log.Printf("✅ Commande %d auto-assignée à %s", commandID, livreur.Username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ProcessNextCommandInQueue(deliveryman string) error {
|
||||
log.Printf("🔄 Traitement de la prochaine commande pour %s", deliveryman)
|
||||
|
||||
// Vérifier d'abord la queue spécifique du livreur
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
results, err := Redis.ZRangeWithScores(RedisCtx, queueKey, 0, 0).Result()
|
||||
|
||||
if err == nil && len(results) > 0 {
|
||||
// Une commande est dans sa queue - la traiter
|
||||
commandID := extractCommandID(results[0].Member)
|
||||
|
||||
// Mettre à jour le statut
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
|
||||
|
||||
log.Printf("✅ Livreur %s traite la commande %d de sa queue", deliveryman, commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Si aucune commande dans sa queue, chercher dans la queue générale
|
||||
generalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
|
||||
|
||||
if err != nil || len(generalResults) == 0 {
|
||||
log.Printf("ℹ️ Aucune commande en attente pour %s", deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
commandID := extractCommandID(generalResults[0].Member)
|
||||
|
||||
// Assigner la commande
|
||||
err = d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur assignation commande %d: %v", commandID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Retirer de la queue générale
|
||||
Redis.ZRem(RedisCtx, "queue:pending:sorted", strconv.Itoa(commandID))
|
||||
|
||||
// Mettre à jour le statut
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
|
||||
|
||||
// Définir un ETA par défaut
|
||||
d.SetCommandETA(commandID, 30)
|
||||
|
||||
// Ajouter log
|
||||
d.AddCommandLog(commandID, "auto_assigned",
|
||||
fmt.Sprintf("Assigné automatiquement à %s depuis la queue générale", deliveryman),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d assignée automatiquement à %s (queue générale)", commandID, deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// db/redis_queue_cleanup.go
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
||||
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
|
||||
|
||||
keys, err := scanRedisKeys("queue:pending:*")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération des clés: %w", err)
|
||||
}
|
||||
|
||||
removedCount := 0
|
||||
validCount := 0
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLEANUP] Impossible de lire %s: %v", key, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
log.Printf("❌ [CLEANUP] JSON invalide pour %s - SUPPRESSION", key)
|
||||
d.removeInvalidCommand(key, queueItem.CommandID, "JSON invalide")
|
||||
removedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
isValid := true
|
||||
reasons := []string{}
|
||||
|
||||
// 1. Vérifier Username
|
||||
if queueItem.Username == "" {
|
||||
isValid = false
|
||||
reasons = append(reasons, "username vide")
|
||||
}
|
||||
|
||||
// 2. Vérifier Address
|
||||
if queueItem.Address == "" {
|
||||
isValid = false
|
||||
reasons = append(reasons, "adresse vide")
|
||||
}
|
||||
|
||||
// 3. Vérifier Coordinates
|
||||
if queueItem.Lat == 0 || queueItem.Lng == 0 {
|
||||
isValid = false
|
||||
reasons = append(reasons, "coordonnées GPS manquantes")
|
||||
}
|
||||
|
||||
// 4. Vérifier CreatedAt
|
||||
if queueItem.CreatedAt.IsZero() || queueItem.CreatedAt.Year() == 1 {
|
||||
isValid = false
|
||||
reasons = append(reasons, "date de création invalide")
|
||||
}
|
||||
|
||||
// 5. Vérifier TotalPrice (optionnel mais recommandé)
|
||||
if queueItem.TotalPrice <= 0 {
|
||||
log.Printf("⚠️ [CLEANUP] Commande %d: prix suspect (%.2f)", queueItem.CommandID, queueItem.TotalPrice)
|
||||
}
|
||||
|
||||
// ❌ SUPPRIMER SI INVALIDE
|
||||
if !isValid {
|
||||
log.Printf("❌ [CLEANUP] Commande %d INVALIDE: %v - SUPPRESSION", queueItem.CommandID, reasons)
|
||||
d.removeInvalidCommand(key, queueItem.CommandID, fmt.Sprintf("%v", reasons))
|
||||
removedCount++
|
||||
} else {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [CLEANUP] Terminé: %d commandes supprimées, %d commandes valides restantes", removedCount, validCount)
|
||||
return removedCount, nil
|
||||
}
|
||||
|
||||
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
// 1. Supprimer la clé de données
|
||||
Redis.Del(RedisCtx, key)
|
||||
|
||||
// 2. Supprimer de la queue générale
|
||||
Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
|
||||
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||
|
||||
// 3. Supprimer des queues de livreurs
|
||||
livreurKeys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
for _, queueKey := range livreurKeys {
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
Redis.ZRem(RedisCtx, queueKey, commandIDStr)
|
||||
}
|
||||
|
||||
// 4. Supprimer l'ETA si existe
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// 5. Supprimer le cache de destination
|
||||
destKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
Redis.Del(RedisCtx, destKey)
|
||||
|
||||
// 6. Logger dans la base de données
|
||||
d.AddCommandLog(commandID, "cleanup_removed",
|
||||
fmt.Sprintf("Commande supprimée de Redis: %s", reason),
|
||||
"system")
|
||||
|
||||
log.Printf("🗑️ [CLEANUP] Commande %d supprimée: %s", commandID, reason)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VALIDATION STRICTE AVANT AJOUT À LA QUEUE
|
||||
// ============================================
|
||||
|
||||
// ValidateCommandBeforeQueue valide qu'une commande a toutes les données requises
|
||||
func (d *Database) ValidateCommandBeforeQueue(commandID int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
// ✅ VALIDATION STRICTE
|
||||
errors := []string{}
|
||||
|
||||
// 1. Username
|
||||
username, ok := command["username"].(string)
|
||||
if !ok || username == "" {
|
||||
errors = append(errors, "username manquant")
|
||||
}
|
||||
|
||||
// 2. Address
|
||||
address := ""
|
||||
if addr, ok := command["delivery_address"].(string); ok && addr != "" {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok && addr != "" {
|
||||
address = addr
|
||||
}
|
||||
if address == "" {
|
||||
errors = append(errors, "adresse de livraison manquante")
|
||||
}
|
||||
|
||||
// 3. Coordinates
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
if lat == 0 || lng == 0 {
|
||||
errors = append(errors, "coordonnées GPS manquantes")
|
||||
}
|
||||
|
||||
// 4. Total Price
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
if totalPrice <= 0 {
|
||||
errors = append(errors, "prix total invalide")
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("validation échouée: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NETTOYAGE AUTOMATIQUE PÉRIODIQUE
|
||||
// ============================================
|
||||
|
||||
// StartQueueCleanupScheduler démarre un nettoyage automatique toutes les 5 minutes
|
||||
func (d *Database) StartQueueCleanupScheduler() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Println("🔄 [CLEANUP] Scheduler de nettoyage démarré (toutes les 5 minutes)")
|
||||
|
||||
for range ticker.C {
|
||||
removed, err := d.CleanupInvalidQueueCommands()
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLEANUP] Erreur: %v", err)
|
||||
} else if removed > 0 {
|
||||
log.Printf("🧹 [CLEANUP] %d commandes invalides supprimées", removed)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetDeliverymanQueueInfo récupère les infos de queue d'un livreur
|
||||
func (d *Database) GetDeliverymanQueueInfo(deliveryman string) (map[string]any, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
var commands []map[string]any
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
commands = append(commands, map[string]any{
|
||||
"position": i + 1,
|
||||
"command_id": commandID,
|
||||
"address": queueItem.Address,
|
||||
"estimated_eta": queueItem.EstimatedETA,
|
||||
"created_at": queueItem.CreatedAt,
|
||||
"lat": queueItem.Lat,
|
||||
"lng": queueItem.Lng,
|
||||
})
|
||||
}
|
||||
|
||||
canAcceptMore := true
|
||||
if activeCount > 1 {
|
||||
canAcceptMore = queueSize < MAX_COMMANDS_PER_DELIVERYMAN
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"deliveryman": deliveryman,
|
||||
"queue_size": queueSize,
|
||||
"commands": commands,
|
||||
"can_accept_more": canAcceptMore,
|
||||
"max_commands": MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"active_deliverymen": activeCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
|
||||
overview := make(map[string]any)
|
||||
|
||||
generalQueueSize, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
||||
overview["general_queue"] = generalQueueSize
|
||||
|
||||
deliverymanQueues := make(map[string]any)
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
|
||||
var totalPending int64 = generalQueueSize
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
username := key[len("queue:deliveryman:"):]
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
|
||||
deliverymanQueues[username] = map[string]any{
|
||||
"queue_size": queueSize,
|
||||
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"capacity": fmt.Sprintf("%d/%d", queueSize, MAX_COMMANDS_PER_DELIVERYMAN),
|
||||
}
|
||||
totalPending += queueSize
|
||||
}
|
||||
overview["total_pending"] = totalPending
|
||||
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetQueueStats() (map[string]any, error) {
|
||||
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
||||
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
|
||||
|
||||
var deliverymanQueueCount int64
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
count, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
deliverymanQueueCount += count
|
||||
}
|
||||
|
||||
var totalWaitTime int64
|
||||
var commandCount int64
|
||||
|
||||
// Limité aux 100 premières entrées pour ne pas bloquer Redis sur une grande queue
|
||||
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 99).Result()
|
||||
for _, result := range normalResults {
|
||||
commandID := extractCommandID(result.Member)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, _ := Redis.Get(RedisCtx, key).Result()
|
||||
|
||||
var item models.CommandQueue
|
||||
if json.Unmarshal([]byte(data), &item) == nil {
|
||||
waitMinutes := int64(time.Since(item.CreatedAt).Minutes())
|
||||
totalWaitTime += waitMinutes
|
||||
commandCount++
|
||||
}
|
||||
}
|
||||
|
||||
avgWaitTime := 0
|
||||
if commandCount > 0 {
|
||||
avgWaitTime = int(totalWaitTime / commandCount)
|
||||
}
|
||||
|
||||
stats := map[string]any{
|
||||
"total_pending": normalCount + priorityCount,
|
||||
"general_queue": normalCount,
|
||||
"priority_queue": priorityCount,
|
||||
"deliveryman_queues": deliverymanQueueCount,
|
||||
"avg_wait_time_min": avgWaitTime,
|
||||
"max_commands_per_driver": MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"avg_delivery_time": AVG_DELIVERY_TIME,
|
||||
"last_updated": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// UpdateDeliverymanStatusBasedOnQueue met à jour automatiquement le statut
|
||||
func (d *Database) UpdateDeliverymanStatusBasedOnQueue(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
queueSize, err := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur récupération taille queue: %w", err)
|
||||
}
|
||||
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
|
||||
data, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [STATUS] Livreur %s n'a pas de statut Redis", deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var newStatus string
|
||||
var currentCommand int
|
||||
|
||||
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
newStatus = "busy"
|
||||
currentCommand = 0
|
||||
log.Printf("🔴 [STATUS] %s -> BUSY (queue pleine: %d/10)", deliveryman, queueSize)
|
||||
} else {
|
||||
if status.Status == "delivering" && status.CurrentCommand > 0 {
|
||||
newStatus = "delivering"
|
||||
currentCommand = status.CurrentCommand
|
||||
log.Printf("🟡 [STATUS] %s -> DELIVERING (queue: %d/10, livraison en cours: cmd %d)",
|
||||
deliveryman, queueSize, currentCommand)
|
||||
} else {
|
||||
newStatus = "available"
|
||||
currentCommand = 0
|
||||
log.Printf("🟢 [STATUS] %s -> AVAILABLE (queue: %d/10)", deliveryman, queueSize)
|
||||
}
|
||||
}
|
||||
|
||||
return d.SetDeliveryPersonStatus(deliveryman, newStatus, currentCommand)
|
||||
}
|
||||
|
||||
// CanDeliverymanAcceptCommands vérifie si un livreur peut accepter de nouvelles commandes
|
||||
func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
|
||||
data, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECK] Livreur %s sans statut Redis", deliveryman)
|
||||
return true
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if status.Status == "offline" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Vérifier la taille de la queue
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// 4. Si queue >= 10, refuser
|
||||
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
log.Printf("🔴 [CHECK] %s REFUSÉ: queue pleine (%d/10)", deliveryman, queueSize)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("🟢 [CHECK] %s AUTORISÉ (%d/10)", deliveryman, queueSize)
|
||||
return true
|
||||
}
|
||||
|
||||
// AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
|
||||
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation: %w", err)
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
|
||||
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
score := float64(time.Now().Unix())
|
||||
|
||||
err = Redis.ZAdd(RedisCtx, queueKey, redis.Z{
|
||||
Score: score,
|
||||
Member: queueItem.CommandID,
|
||||
}).Err()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout queue Redis: %w", err)
|
||||
}
|
||||
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Incr(RedisCtx, counterKey)
|
||||
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddToGeneralQueue ajoute une commande à la queue générale (fallback)
|
||||
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation: %w", err)
|
||||
}
|
||||
|
||||
score := float64(time.Now().Unix())
|
||||
key := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.Set(RedisCtx, key, data, 24*time.Hour)
|
||||
pipe.ZAdd(RedisCtx, "queue:pending:sorted", redis.Z{
|
||||
Score: score,
|
||||
Member: queueItem.CommandID,
|
||||
})
|
||||
|
||||
_, err = pipe.Exec(RedisCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout queue générale: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📥 Commande %d ajoutée à la queue générale", queueItem.CommandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) RemoveCommandFromQueue(commandID int) error {
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
commandIDStr := strconv.Itoa(commandID)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.Del(RedisCtx, key)
|
||||
pipe.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
|
||||
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||
|
||||
// Trouver et retirer de la queue du livreur
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
var affectedDeliveryman string
|
||||
|
||||
for _, queueKey := range keys {
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Vérifier si la commande est dans cette queue
|
||||
_, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err == nil {
|
||||
// Commande trouvée dans cette queue
|
||||
affectedDeliveryman = queueKey[len("queue:deliveryman:"):]
|
||||
pipe.ZRem(RedisCtx, queueKey, commandIDStr)
|
||||
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", affectedDeliveryman)
|
||||
pipe.Decr(RedisCtx, counterKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_, err := pipe.Exec(RedisCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur suppression: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Commande %d retirée de la file", commandID)
|
||||
|
||||
// ✅ NOUVEAU: Mettre à jour le statut si un livreur était affecté
|
||||
if affectedDeliveryman != "" {
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(affectedDeliveryman)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
|
||||
|
||||
normalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
|
||||
|
||||
if err != nil || len(normalResults) == 0 {
|
||||
return nil, fmt.Errorf("aucune commande en attente")
|
||||
}
|
||||
|
||||
commandID := extractCommandID(normalResults[0].Member)
|
||||
if commandID <= 0 {
|
||||
return nil, fmt.Errorf("ID commande invalide")
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
var queue models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queue); err != nil {
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
return &queue, nil
|
||||
}
|
||||
|
||||
func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
|
||||
key := fmt.Sprintf("delivery:status:%s", username)
|
||||
|
||||
statusData := models.DeliveryPersonStatus{
|
||||
Username: username,
|
||||
Status: status,
|
||||
CurrentCommand: commandID,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(statusData)
|
||||
err := Redis.Set(RedisCtx, key, data, 24*time.Hour).Err()
|
||||
|
||||
if err == nil {
|
||||
log.Printf("✅ Statut livreur %s: %s", username, status)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsRedis récupère les livreurs disponibles (LEGACY)
|
||||
func (d *Database) GetAvailableDeliveryPersonsRedis() ([]models.DeliveryPersonStatus, error) {
|
||||
var available []models.DeliveryPersonStatus
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status == "available" {
|
||||
available = append(available, s)
|
||||
}
|
||||
})
|
||||
return available, err
|
||||
}
|
||||
|
||||
// GetAllActiveDeliveryPersons retourne les livreurs actifs pouvant accepter des commandes
|
||||
func (d *Database) GetAllActiveDeliveryPersons() ([]models.DeliveryPersonStatus, error) {
|
||||
var active []models.DeliveryPersonStatus
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status != "offline" && d.CanDeliverymanAcceptCommands(s.Username) {
|
||||
active = append(active, s)
|
||||
}
|
||||
})
|
||||
return active, err
|
||||
}
|
||||
|
||||
// CountActiveDeliverymen compte le nombre de livreurs actifs (non offline)
|
||||
func (d *Database) CountActiveDeliverymen() (int, error) {
|
||||
count := 0
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status != "offline" {
|
||||
count++
|
||||
}
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (d *Database) GetSingleActiveDeliveryman() (string, error) {
|
||||
found := ""
|
||||
d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) { //nolint
|
||||
if found == "" && s.Status != "offline" {
|
||||
found = s.Username
|
||||
}
|
||||
})
|
||||
if found == "" {
|
||||
return "", fmt.Errorf("aucun livreur actif trouvé")
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// GetAllActiveDeliverymenUsernames retourne les usernames de tous les livreurs actifs
|
||||
func (d *Database) GetAllActiveDeliverymenUsernames() ([]string, error) {
|
||||
var usernames []string
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status != "offline" {
|
||||
usernames = append(usernames, s.Username)
|
||||
}
|
||||
})
|
||||
return usernames, err
|
||||
}
|
||||
|
||||
// SyncAllDeliverymanStatuses synchronise tous les statuts (à appeler au démarrage)
|
||||
func (d *Database) SyncAllDeliverymanStatuses() error {
|
||||
log.Println("🔄 [SYNC] Synchronisation des statuts livreurs...")
|
||||
return d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(s.Username)
|
||||
})
|
||||
}
|
||||
|
||||
// iterDeliveryStatuses itère sur tous les statuts Redis des livreurs et appelle fn pour chacun.
|
||||
func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) error {
|
||||
keys, err := scanRedisKeys("delivery:status:*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
continue
|
||||
}
|
||||
fn(status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanRedisKeys(pattern string) ([]string, error) {
|
||||
var all []string
|
||||
cursor := uint64(0)
|
||||
for {
|
||||
batch, next, err := Redis.Scan(RedisCtx, cursor, pattern, 200).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// CompleteDeliveryAndProcessNext marque une livraison comme terminée et optimise la queue
|
||||
func (d *Database) CompleteDeliveryAndProcessNext(deliveryman string, completedCommandID int) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Retirer la commande complétée de la queue
|
||||
Redis.ZRem(RedisCtx, queueKey, strconv.Itoa(completedCommandID))
|
||||
|
||||
// Supprimer les données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
// Supprimer le cache de destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, destCacheKey)
|
||||
|
||||
// Supprimer l'ETA
|
||||
etaKey := fmt.Sprintf("command:eta:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
|
||||
log.Printf("✅ Livraison %d complétée par %s", completedCommandID, deliveryman)
|
||||
|
||||
// Vérifier s'il reste des commandes
|
||||
remainingCount, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if remainingCount == 0 {
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
log.Printf("🔓 Livreur %s libéré - Plus de commandes en queue", deliveryman)
|
||||
|
||||
// Chercher dans la queue générale
|
||||
go d.ProcessNextCommandInQueue(deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔄 OPTIMISATION PAR PROXIMITÉ
|
||||
// ============================================
|
||||
log.Printf("🔄 Optimisation queue de %s: %d commande(s) restante(s)", deliveryman, remainingCount)
|
||||
|
||||
err := d.OptimizeDeliverymanQueueByProximity(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur optimisation queue: %v", err)
|
||||
// Fallback: recalculer les ETAs sans réorganiser
|
||||
d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
// Récupérer la prochaine commande (maintenant la plus proche)
|
||||
nextCommand, nextETA, err := d.FindNearestCommandInQueue(deliveryman)
|
||||
if err == nil && nextCommand != nil {
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", nextCommand.CommandID)
|
||||
log.Printf("📍 Prochaine livraison: Commande %d (ETA: %d min)", nextCommand.CommandID, nextETA)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) OptimizeDeliverymanQueueByProximity(deliveryman string) error {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Position livreur %s non disponible, recalcul ETAs simple", deliveryman)
|
||||
return d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collecter les informations de chaque commande avec sa distance
|
||||
var commandsWithDistance []CommandWithDistance
|
||||
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Essayer de récupérer depuis queue:pending:{id}
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
|
||||
var lat, lng float64
|
||||
var address string
|
||||
|
||||
if err == nil && data != "" {
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err == nil {
|
||||
lat = queueItem.Lat
|
||||
lng = queueItem.Lng
|
||||
address = queueItem.Address
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si coordonnées à 0, essayer le cache command:destination:{id}
|
||||
if lat == 0 && lng == 0 {
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, err := Redis.Get(RedisCtx, destCacheKey).Result()
|
||||
if err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil {
|
||||
lat = coords.Lat
|
||||
lng = coords.Lon
|
||||
log.Printf("📍 Coordonnées récupérées depuis cache destination pour commande %d: (%.6f, %.6f)", commandID, lat, lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Si toujours 0, récupérer depuis la DB
|
||||
if lat == 0 && lng == 0 {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err == nil {
|
||||
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
|
||||
lat = dLat
|
||||
}
|
||||
if dLng, ok := command["dest_longitude"].(float64); ok && dLng != 0 {
|
||||
lng = dLng
|
||||
}
|
||||
if address == "" {
|
||||
if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Si toujours 0, utiliser une distance très grande
|
||||
if lat == 0 && lng == 0 {
|
||||
log.Printf("⚠️ Coordonnées non disponibles pour commande %d, utilisation position par défaut", commandID)
|
||||
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: 0,
|
||||
Lng: 0,
|
||||
Distance: 999999,
|
||||
EstimatedETA: 120,
|
||||
QueueItem: models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: 0,
|
||||
Lng: 0,
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: Distance depuis la position ACTUELLE du livreur (pas chaînée)
|
||||
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, lat, lng)
|
||||
|
||||
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Distance: distance,
|
||||
EstimatedETA: services.CalculateETA(distance),
|
||||
QueueItem: models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(commandsWithDistance) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trier par distance (la plus proche en premier)
|
||||
sort.Slice(commandsWithDistance, func(i, j int) bool {
|
||||
return commandsWithDistance[i].Distance < commandsWithDistance[j].Distance
|
||||
})
|
||||
|
||||
log.Printf("🔄 Optimisation queue de %s: %d commandes triées par proximité", deliveryman, len(commandsWithDistance))
|
||||
|
||||
// Vider la queue actuelle
|
||||
Redis.Del(RedisCtx, queueKey)
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
|
||||
|
||||
// ✅ MODIFIÉ: Recréer la queue avec ETA = distance directe depuis position livreur
|
||||
for i, cmd := range commandsWithDistance {
|
||||
var travelTime int
|
||||
var distance float64
|
||||
|
||||
if cmd.Lat != 0 && cmd.Lng != 0 {
|
||||
// ✅ Distance depuis la position ACTUELLE du livreur (pas cumulative)
|
||||
distance = d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, cmd.Lat, cmd.Lng)
|
||||
travelTime = services.CalculateETA(distance)
|
||||
} else {
|
||||
distance = 0
|
||||
travelTime = 10
|
||||
}
|
||||
|
||||
// ✅ ETA = temps de trajet direct uniquement
|
||||
cmd.QueueItem.EstimatedETA = travelTime
|
||||
|
||||
score := float64(i + 1)
|
||||
|
||||
data, _ := json.Marshal(cmd.QueueItem)
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", cmd.CommandID)
|
||||
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
|
||||
|
||||
Redis.ZAdd(RedisCtx, queueKey, redis.Z{
|
||||
Score: score,
|
||||
Member: cmd.CommandID,
|
||||
})
|
||||
|
||||
d.SetCommandETAWithDetails(cmd.CommandID, travelTime, i+1)
|
||||
|
||||
log.Printf(" 📍 Position %d: Commande %d - %.2f km - ETA trajet: %d min",
|
||||
i+1, cmd.CommandID, distance, travelTime)
|
||||
}
|
||||
|
||||
Redis.Set(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman), len(commandsWithDistance), 0)
|
||||
|
||||
log.Printf("✅ Queue de %s optimisée: %d commandes réorganisées par proximité", deliveryman, len(commandsWithDistance))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) RecalculateQueueETAs(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ Récupérer la position actuelle du livreur
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Position livreur %s non disponible pour recalcul ETA", deliveryman)
|
||||
return err
|
||||
}
|
||||
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: ETA = distance directe depuis position livreur
|
||||
travelTime := d.CalculateETABetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
d.SetCommandETAWithDetails(commandID, travelTime, i+1)
|
||||
|
||||
queueItem.EstimatedETA = travelTime
|
||||
updatedData, _ := json.Marshal(queueItem)
|
||||
Redis.Set(RedisCtx, commandKey, updatedData, 24*time.Hour)
|
||||
}
|
||||
|
||||
log.Printf("🔄 ETAs recalculés pour %d commandes de %s (trajet direct)", len(commandIDs), deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindNearestCommandInQueue trouve la commande la plus proche du livreur
|
||||
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("position livreur non disponible: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil, 0, fmt.Errorf("queue vide")
|
||||
}
|
||||
|
||||
var nearestCommand *models.CommandQueue
|
||||
var nearestDistance float64 = math.MaxFloat64
|
||||
var nearestETA int
|
||||
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
if distance < nearestDistance {
|
||||
nearestDistance = distance
|
||||
nearestCommand = &queueItem
|
||||
nearestETA = services.CalculateETA(distance)
|
||||
}
|
||||
}
|
||||
|
||||
if nearestCommand == nil {
|
||||
return nil, 0, fmt.Errorf("aucune commande trouvée")
|
||||
}
|
||||
|
||||
return nearestCommand, nearestETA, nil
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// SESSION REDIS - GESTION UTILISATEUR
|
||||
// ============================================
|
||||
|
||||
// SessionData représente une session client en Redis
|
||||
type SessionData struct {
|
||||
ClientID int `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
SessionID string `json:"session_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastActivity int64 `json:"last_activity"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
BasketVersion int `json:"basket_version"`
|
||||
PointsCache int `json:"points_cache"`
|
||||
PenaltyCache float64 `json:"penalty_cache"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRÉER UNE SESSION CLIENT
|
||||
// ============================================
|
||||
|
||||
// CreateClientSession crée une session Redis pour un client authentifié
|
||||
// Appelé depuis handlers/auth.go après LoginClient réussi
|
||||
//
|
||||
// Exemple d'utilisation:
|
||||
//
|
||||
// sessionID := uuid.New().String()
|
||||
// database.CreateClientSession(clientID, username, sessionID)
|
||||
func (d *Database) CreateClientSession(clientID int, username string, sessionID string) error {
|
||||
log.Printf("📝 [SESSION] Création session pour client: %s (ID: %d)", username, clientID)
|
||||
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
now := time.Now().Unix()
|
||||
expiresAt := now + (5 * 3600) // 5 heures
|
||||
|
||||
sessionData := SessionData{
|
||||
ClientID: clientID,
|
||||
Username: username,
|
||||
SessionID: sessionID,
|
||||
Role: "client",
|
||||
CreatedAt: now,
|
||||
LastActivity: now,
|
||||
ExpiresAt: expiresAt,
|
||||
BasketVersion: 0,
|
||||
PointsCache: 0,
|
||||
PenaltyCache: 0,
|
||||
}
|
||||
|
||||
// Sérialiser et sauvegarder
|
||||
sessionJSON, err := json.Marshal(sessionData)
|
||||
if err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur sérialisation: %v", err)
|
||||
return fmt.Errorf("erreur sérialisation session: %w", err)
|
||||
}
|
||||
|
||||
ttl := time.Duration(expiresAt-now) * time.Second
|
||||
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur sauvegarde Redis: %v", err)
|
||||
return fmt.Errorf("erreur sauvegarde session Redis: %w", err)
|
||||
}
|
||||
|
||||
// Ajouter à l'index des sessions actives
|
||||
if err := Redis.SAdd(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur ajout index: %v", err)
|
||||
}
|
||||
|
||||
// Charger les infos du client (points, penalty) dans le cache
|
||||
if client, err := d.GetClientByUsername(username); err == nil {
|
||||
sessionData.PenaltyCache = float64(client.Amende)
|
||||
sessionJSON, _ := json.Marshal(sessionData)
|
||||
Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl)
|
||||
}
|
||||
|
||||
log.Printf("✅ [SESSION] Session créée pour %s - TTL: 5h", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RÉCUPÉRER UNE SESSION CLIENT
|
||||
// ============================================
|
||||
|
||||
// GetClientSession récupère la session Redis d'un client
|
||||
// Retourne nil si session expirée ou inexistante
|
||||
func (d *Database) GetClientSession(clientID int) (*SessionData, error) {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
|
||||
data, err := Redis.Get(RedisCtx, sessionKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [SESSION] Pas de session trouvée pour client %d", clientID)
|
||||
return nil, fmt.Errorf("session non trouvée")
|
||||
}
|
||||
|
||||
var session SessionData
|
||||
if err := json.Unmarshal([]byte(data), &session); err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur désérialisation: %v", err)
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Vérifier si session expirée
|
||||
if time.Now().Unix() > session.ExpiresAt {
|
||||
log.Printf("⚠️ [SESSION] Session expirée pour client %d", clientID)
|
||||
Redis.Del(RedisCtx, sessionKey)
|
||||
return nil, fmt.Errorf("session expirée")
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// METTRE À JOUR L'ACTIVITÉ DE SESSION
|
||||
// ============================================
|
||||
|
||||
// RefreshSessionTimeout prolonge la durée de vie de la session
|
||||
// Appelé régulièrement par SessionMiddleware (chaque requête client)
|
||||
func (d *Database) RefreshSessionTimeout(clientID int) error {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
|
||||
// Récupérer la session
|
||||
data, err := Redis.Get(RedisCtx, sessionKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("session non trouvée")
|
||||
}
|
||||
|
||||
var session SessionData
|
||||
if err := json.Unmarshal([]byte(data), &session); err != nil {
|
||||
return fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Mettre à jour lastActivity et expiresAt
|
||||
now := time.Now().Unix()
|
||||
session.LastActivity = now
|
||||
session.ExpiresAt = now + (5 * 3600) // Prolonger de 5 heures
|
||||
|
||||
// Resauvegarder
|
||||
sessionJSON, _ := json.Marshal(session)
|
||||
ttl := time.Duration(session.ExpiresAt-now) * time.Second
|
||||
|
||||
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur refresh: %v", err)
|
||||
return nil // Pas critique
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INVALIDER UNE SESSION (LOGOUT)
|
||||
// ============================================
|
||||
|
||||
// InvalidateSession supprime la session Redis (logout)
|
||||
// Appelé depuis handlers/auth.go dans LogoutClient
|
||||
func (d *Database) InvalidateSession(clientID int) error {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
|
||||
// Supprimer la session
|
||||
if err := Redis.Del(RedisCtx, sessionKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur suppression: %v", err)
|
||||
}
|
||||
|
||||
// Vider le panier Redis
|
||||
if err := Redis.Del(RedisCtx, basketKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur suppression panier: %v", err)
|
||||
}
|
||||
|
||||
// Retirer de l'index
|
||||
if err := Redis.SRem(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur retrait index: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
|
||||
return nil
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: gestion_postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
ports:
|
||||
- "${DB_PORT}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- gestion-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
container_name: gestion_redis
|
||||
restart: unless-stopped
|
||||
command:
|
||||
[
|
||||
"redis-server",
|
||||
"--appendonly",
|
||||
"yes",
|
||||
"--requirepass",
|
||||
"${REDIS_PASSWORD}",
|
||||
]
|
||||
ports:
|
||||
- "${REDIS_PORT}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- gestion-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
gestion-net:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
@@ -1,79 +0,0 @@
|
||||
module gestion
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.9
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-contrib/sessions v1.0.4
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/redis/go-redis/v9 v9.17.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/text v0.27.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
||||
github.com/aws/smithy-go v1.27.1 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
@@ -1,172 +0,0 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
|
||||
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
|
||||
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
||||
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -1,77 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AddAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CorrectAddress string `json:"correct_address" binding:"required"`
|
||||
InvalidAddress string `json:"invalid_address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.AddAddress(req.CorrectAddress, req.InvalidAddress); err != nil {
|
||||
utils.ServerErr(c, "Impossible d'ajouter l'adresse", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Adresses ajoutées avec succès"})
|
||||
}
|
||||
|
||||
func DeleteAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
CorrectAddress string `json:"correct_address" binding:"required"`
|
||||
InvalidAddress string `json:"invalid_address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteAddress(req.InvalidAddress, req.CorrectAddress); err != nil {
|
||||
utils.ServerErr(c, "Impossible de supprimer l'adresse", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Adresses supprimées avec succès"})
|
||||
}
|
||||
|
||||
func GetAllAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
getAddress, err := database.AllAddress()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les adresses", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"addresses": getAddress})
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AlertPolice(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
usernameStr := username.(string)
|
||||
alert, err := database.CreateAlert(usernameStr, req.Message)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de créer l'alerte", err)
|
||||
return
|
||||
}
|
||||
|
||||
go database.NotifyAllAdminCabineAlert(alert.ID, usernameStr, req.Message)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Police alert created",
|
||||
"alert_id": alert.ID,
|
||||
"user": alert.Username,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteAlert(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
alertIDStr := c.Param("id")
|
||||
alertID, err := strconv.Atoi(alertIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
// Un livreur ne peut supprimer que ses propres alertes — admin garde l'accès complet.
|
||||
if userRole == "livreur" {
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
alert, err := database.GetAlertPolicy(alertID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
|
||||
return
|
||||
}
|
||||
if alert.Username != username.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = database.DeleteAlertPolicy(alertID); err != nil {
|
||||
utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Alert deleted",
|
||||
})
|
||||
}
|
||||
|
||||
func GetAlert(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
alertIDStr := c.Param("id")
|
||||
alertID, err := strconv.Atoi(alertIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" && userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
alert, err := database.GetAlertPolicy(alertID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Un livreur ne peut consulter que ses propres alertes — admin/cabine gardent l'accès complet pour le dispatch
|
||||
if userRole == "livreur" {
|
||||
username, exists := c.Get("username")
|
||||
if !exists || alert.Username != username.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alert": alert,
|
||||
})
|
||||
}
|
||||
|
||||
// EndAlert permet au livreur de mettre fin à son alerte active
|
||||
func EndAlert(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
alertIDStr := c.Param("id")
|
||||
alertID, err := strconv.Atoi(alertIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
alert, err := database.GetAlertPolicy(alertID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
if alert.Username != usernameStr {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
|
||||
if err = database.EndAlert(alertID); err != nil {
|
||||
utils.ServerErr(c, "Impossible de mettre fin à l'alerte", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Alerte terminée avec succès",
|
||||
"alert_id": alertID,
|
||||
"user": usernameStr,
|
||||
})
|
||||
}
|
||||
|
||||
func GetMyAlerts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
alerts, err := database.GetAlertsByUsername(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
"user": usernameStr,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllAlerts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
alerts, err := database.GetAllAlerts()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
})
|
||||
}
|
||||
|
||||
func GetActiveAlerts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
alerts, err := database.GetActiveAlerts()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
})
|
||||
}
|
||||
@@ -1,707 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
clientTokenDuration = 5 * time.Hour
|
||||
adminTokenDuration = 10 * time.Hour
|
||||
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET"))
|
||||
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET"))
|
||||
)
|
||||
|
||||
func generateClientToken(client *models.Client) (string, error) {
|
||||
sessionID := utils.GenerateSessionID()
|
||||
claims := models.ClientClaims{
|
||||
ClientID: client.ID,
|
||||
Username: client.Username,
|
||||
Role: "client",
|
||||
SessionID: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(clientTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "api-client",
|
||||
Subject: strconv.Itoa(client.ID),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(userJWTSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func generateAdminToken(user *models.User) (string, error) {
|
||||
sessionID := utils.GenerateSessionID()
|
||||
claims := models.AdminClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
SessionID: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "api-admin",
|
||||
Subject: strconv.Itoa(user.ID),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(adminJWTSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
|
||||
func AdminCreateClient(c *gin.Context) {
|
||||
if userRole := c.GetString("role"); userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des clients"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.RegisterClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [ADMIN_CREATE_CLIENT] Binding error: %v | body: username=%q nom=%q prenom=%q tel=%q", err, req.Username, req.Nom, req.Prenom, req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize text inputs
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [ADMIN_CREATE_CLIENT] Téléphone invalide: %q", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if existing, _ := database.GetClientByUsername(req.Username); existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
if existing, _ := database.GetClientByTelephone(normalizedPhone); existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
client := &models.Client{
|
||||
Username: req.Username,
|
||||
Password: string(hashed),
|
||||
Nom: strings.TrimSpace(req.Nom),
|
||||
Prenom: strings.TrimSpace(req.Prenom),
|
||||
Telephone: normalizedPhone,
|
||||
MustChangePassword: true,
|
||||
}
|
||||
|
||||
if err := database.CreateClient(client); err != nil {
|
||||
log.Printf("❌ [ADMIN_CREATE_CLIENT] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Client créé avec succès",
|
||||
"client": gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func cryptoRandInt() int {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
||||
}
|
||||
|
||||
// LoginClient authentifie un client
|
||||
func LoginClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(req.Username)
|
||||
if err != nil || client == nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.Password)); err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Mot de passe invalide: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur récupération des paramètres: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
|
||||
if settings.Telegram2FAEnabled && client.TwoFAEnabled {
|
||||
chatID, linked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
if linked {
|
||||
code := fmt.Sprintf("%06d", cryptoRandInt()%1000000)
|
||||
sessionToken := uuid.New().String()
|
||||
if err := db.Store2FASession(sessionToken, client.Username, code); err != nil {
|
||||
log.Printf("❌ [2FA] Erreur stockage session Redis: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("🔐 Code de vérification : <b>%s</b>\n\nValable 5 minutes.", code)
|
||||
services.TelegramBot.SendMessage(chatID, msg)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requires_2fa": true,
|
||||
"session_token": sessionToken,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(clientTokenDuration)
|
||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"role": "client",
|
||||
"session_id": sessionID,
|
||||
"must_change_password": client.MustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func Verify2FAClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
SessionToken string `json:"session_token" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
username, err := db.Verify2FASession(req.SessionToken, req.Code)
|
||||
if err != nil {
|
||||
log.Printf("❌ [2FA] Échec vérification: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(username)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(clientTokenDuration)
|
||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [2FA] Erreur session Redis: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"role": "client",
|
||||
"session_id": sessionID,
|
||||
"must_change_password": client.MustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetClient2FAStatus(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
_, tgLinked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"two_fa_enabled": client.TwoFAEnabled,
|
||||
"telegram_linked": tgLinked,
|
||||
"admin_2fa_enabled": settings.Telegram2FAEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func ToggleClient2FA(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled {
|
||||
_, linked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
if !linked {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram non lié — impossible d'activer la 2FA"})
|
||||
return
|
||||
}
|
||||
settings, _ := database.GetSettings()
|
||||
if !settings.Telegram2FAEnabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "La 2FA n'est pas activée par l'administrateur"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.SetClientTwoFAEnabled(clientID, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "two_fa_enabled": req.Enabled})
|
||||
}
|
||||
|
||||
// ChangePassword permet à un client de changer son mot de passe
|
||||
func ChangePassword(c *gin.Context) {
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.CurrentPassword)); err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Mot de passe actuel invalide: ID=%d", clientID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Mot de passe actuel incorrect"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateClientPasswordAndClearFlag(clientID, string(hashed)); err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Erreur update: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Mot de passe mis à jour avec succès"})
|
||||
}
|
||||
|
||||
// LogoutClient déconnecte un client
|
||||
func LogoutClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
clientID, hasClientID := c.Get("client_id")
|
||||
if hasClientID && clientID != nil {
|
||||
if err := database.InvalidateSession(clientID.(int)); err != nil {
|
||||
log.Printf("⚠️ [LOGOUT_CLIENT] Erreur invalidation session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database.RevokeToken(tokenStr)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
|
||||
}
|
||||
|
||||
// LoginAdmin authentifie un admin/cabine/livreur
|
||||
func LoginAdmin(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
user, err := database.GetUserByUsername(req.Username)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] User non trouvé: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" && user.Role != "cabine" && user.Role != "livreur" {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Rôle invalide: %s", user.Role)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Accès non autorisé"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Password == "" {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Mot de passe vide en base")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Mot de passe invalide: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role == "livreur" {
|
||||
if err := database.RecordLivreurLogin(user.Username); err != nil {
|
||||
log.Printf("⚠️ [LOGIN_ADMIN] Erreur enregistrement historique connexion livreur: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
token, _ := generateAdminToken(user)
|
||||
|
||||
expiresAt := time.Now().Add(adminTokenDuration)
|
||||
if err := database.SaveToken(user.ID, user.Role, token, expiresAt); err != nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(adminTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// LogoutAdmin déconnecte un admin/cabine/livreur
|
||||
func LogoutAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Révoquer le JWT token
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database.RevokeToken(tokenStr)
|
||||
}
|
||||
|
||||
log.Printf("✅ [LOGOUT_ADMIN] User déconnecté")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
||||
func GetAllUsers(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
users, err := database.GetAllUsers()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
||||
return
|
||||
}
|
||||
|
||||
var sanitized []gin.H
|
||||
for _, u := range users {
|
||||
sanitized = append(sanitized, gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"role": u.Role,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": sanitized,
|
||||
"count": len(sanitized),
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllDeliveryMen(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
users, err := database.GetAllDeliveryMen()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
||||
return
|
||||
}
|
||||
var sanitized []gin.H
|
||||
for _, u := range users {
|
||||
sanitized = append(sanitized, gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"role": u.Role,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": sanitized,
|
||||
"count": len(sanitized),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllClients récupère tous les clients
|
||||
// GET /api/v1/admin/clients
|
||||
func GetAllClients(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
clients, err := database.GetAllClients()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_CLIENTS] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
||||
return
|
||||
}
|
||||
|
||||
var sanitized []gin.H
|
||||
for _, cl := range clients {
|
||||
sanitized = append(sanitized, gin.H{
|
||||
"id": cl.ID,
|
||||
"username": cl.Username,
|
||||
"nom": cl.Nom,
|
||||
"prenom": cl.Prenom,
|
||||
"telephone": cl.Telephone,
|
||||
"command": cl.Command,
|
||||
"points_extra": cl.PointsExtra,
|
||||
"amende": cl.Amende,
|
||||
"cancellations_count": cl.CancellationsCount,
|
||||
"last_penalty_reason": cl.LastPenaltyReason,
|
||||
"referral_balance": cl.ReferralBalance,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_CLIENTS] %d clients récupérés", len(sanitized))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clients": sanitized,
|
||||
"count": len(sanitized),
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteUser(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_USER] ID invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
err = database.DeleteUser(id)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_USER] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DELETE_USER] Utilisateur %d supprimé", id)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Utilisateur supprimé"})
|
||||
}
|
||||
|
||||
// DeleteClient
|
||||
func DeleteClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_CLIENT] ID invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
err = database.DeleteClient(id)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_CLIENT] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DELETE_CLIENT] Client %d supprimé", id)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Client supprimé"})
|
||||
}
|
||||
|
||||
func CreateUser(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var user models.User
|
||||
if err := c.ShouldBindJSON(&user); err != nil {
|
||||
log.Printf("❌ [CREATE_USER] Erreur de liaison JSON: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
|
||||
return
|
||||
}
|
||||
if c.GetString("role") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des utilisateurs"})
|
||||
return
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "La création d'un compte administrateur n'est pas autorisée via l'application"})
|
||||
return
|
||||
}
|
||||
if user.Role != "livreur" && user.Role != "cabine" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Rôle invalide, valeurs acceptées : livreur, cabine"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CREATE_USER] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
user.Password = string(hashed)
|
||||
|
||||
if err := database.CreateUser(&user); err != nil {
|
||||
log.Printf("❌ [CREATE_USER] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role)
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
|
||||
}
|
||||
|
||||
func Health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetLivreurPosition(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
livreurUsername := c.Param("username")
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès refusé - Réservé aux administrateurs et cabines",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if livreurUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username livreur requis"})
|
||||
return
|
||||
}
|
||||
|
||||
position, err := database.GetLivreurPosition(livreurUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"livreur": livreurUsername,
|
||||
"message": "Position non disponible (GPS désactivé ou livraison terminée)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"livreur": livreurUsername,
|
||||
"position": position,
|
||||
})
|
||||
}
|
||||
|
||||
func GetDeliveryIssues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
status := c.Query("status")
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès refusé - Réservé aux administrateurs et cabines",
|
||||
})
|
||||
return
|
||||
}
|
||||
issues, err := database.GetDeliveryIssues(status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération problèmes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"issues": issues,
|
||||
"count": len(issues),
|
||||
})
|
||||
}
|
||||
|
||||
func CreateDeliveryIssue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
CommandID int `json:"command_id" binding:"required"`
|
||||
IssueType string `json:"issue_type" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
|
||||
issue, err := database.CreateDeliveryIssue(
|
||||
req.CommandID,
|
||||
req.IssueType,
|
||||
req.Description,
|
||||
cabineUsername.(string),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur création problème",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Problème enregistré",
|
||||
"issue": issue,
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateDeliveryIssue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
issueID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Resolution string `json:"resolution"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
|
||||
err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Problème mis à jour",
|
||||
})
|
||||
}
|
||||
|
||||
func GetCommandLogs(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération logs",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"logs": logs,
|
||||
"count": len(logs),
|
||||
})
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
cancelRateLimitMap = make(map[string][]time.Time)
|
||||
cancelMaxRequests = 5 // Max 5 annulations
|
||||
cancelTimeWindow = time.Hour // Par heure
|
||||
)
|
||||
|
||||
func checkCancelRateLimit(key string) bool {
|
||||
now := time.Now()
|
||||
|
||||
if timestamps, exists := cancelRateLimitMap[key]; exists {
|
||||
var validTimestamps []time.Time
|
||||
for _, ts := range timestamps {
|
||||
if now.Sub(ts) < cancelTimeWindow {
|
||||
validTimestamps = append(validTimestamps, ts)
|
||||
}
|
||||
}
|
||||
cancelRateLimitMap[key] = validTimestamps
|
||||
|
||||
if len(validTimestamps) >= cancelMaxRequests {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
cancelRateLimitMap[key] = append(cancelRateLimitMap[key], now)
|
||||
return true
|
||||
}
|
||||
|
||||
func validateReason(reason string) string {
|
||||
if len(reason) > 500 {
|
||||
reason = reason[:500]
|
||||
}
|
||||
reason = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, reason)
|
||||
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
return "Annulation par le client"
|
||||
}
|
||||
|
||||
return reason
|
||||
}
|
||||
|
||||
func CancelCommandByClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil || c.GetString("role") != "client" {
|
||||
log.Printf("❌ [CANCEL_CLIENT] Accès refusé")
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"})
|
||||
return
|
||||
}
|
||||
|
||||
rateLimitKey := fmt.Sprintf("cancel:%s", username)
|
||||
if !checkCancelRateLimit(rateLimitKey) {
|
||||
log.Printf("⚠️ [CANCEL_CLIENT] Rate limit dépassé pour %s", username)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Trop d'annulations récentes",
|
||||
"message": "Veuillez attendre avant de réessayer",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || commandID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.Reason = "Annulation par le client"
|
||||
req.Force = false
|
||||
}
|
||||
|
||||
req.Reason = validateReason(req.Reason)
|
||||
|
||||
penalty, err := database.CancelCommandAtomic(commandID, username, req.Reason, req.Force)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [CANCEL_CLIENT] Erreur: %v", err)
|
||||
|
||||
if err.Error() == "confirmation requise" {
|
||||
command, errCmd := database.GetCommandByID(commandID)
|
||||
if errCmd != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
currentStatus, _ := command["status"].(string)
|
||||
|
||||
hasETA := false
|
||||
if livreurAssign != "" {
|
||||
hasETA = database.CheckCommandETAExistsAndValid(commandID)
|
||||
}
|
||||
|
||||
nextPenalty, _ := database.CalculateCancellationPenalty(username)
|
||||
cancelCount, _ := database.GetClientCancellationsCount(username)
|
||||
|
||||
response := gin.H{
|
||||
"success": false,
|
||||
"warning": true,
|
||||
"command_info": gin.H{
|
||||
"command_id": commandID,
|
||||
"status": currentStatus,
|
||||
"livreur": livreurAssign,
|
||||
},
|
||||
}
|
||||
|
||||
if hasETA {
|
||||
log.Printf("⚠️ [CANCEL_CLIENT] Annulation tardive avec ETA - Status: %s, Livreur: %s", currentStatus, livreurAssign)
|
||||
|
||||
response["message"] = "⚠️ Un livreur est en route vers votre adresse (ETA définie)"
|
||||
response["details"] = gin.H{
|
||||
"livreur": livreurAssign,
|
||||
"status": currentStatus,
|
||||
"has_eta": true,
|
||||
}
|
||||
response["penalty_warning"] = gin.H{
|
||||
"will_apply": true,
|
||||
"penalty_amount": nextPenalty,
|
||||
"current_violations": cancelCount,
|
||||
"message": fmt.Sprintf(
|
||||
"⚠️ ATTENTION: Une amende de %d sera appliquée pour annulation tardive",
|
||||
nextPenalty,
|
||||
),
|
||||
"scale": gin.H{
|
||||
"1st_cancel": 20,
|
||||
"2nd_cancel": 50,
|
||||
"3rd_cancel": 100,
|
||||
"4th+_cancel": 150,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
log.Printf("ℹ️ [CANCEL_CLIENT] Livreur assigné mais pas d'ETA - Annulation sans pénalité")
|
||||
|
||||
response["message"] = "ℹ️ Un livreur est assigné mais n'est pas encore en route"
|
||||
response["details"] = gin.H{
|
||||
"livreur": livreurAssign,
|
||||
"status": currentStatus,
|
||||
"has_eta": false,
|
||||
}
|
||||
response["penalty_warning"] = gin.H{
|
||||
"will_apply": false,
|
||||
"message": "Aucune pénalité ne sera appliquée car le livreur n'est pas encore en route",
|
||||
"points_safe": true,
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ AJOUTER LES INSTRUCTIONS D'ACTION
|
||||
response["action_required"] = "Pour confirmer l'annulation, renvoyez la même requête avec 'force': true"
|
||||
response["example"] = gin.H{
|
||||
"reason": req.Reason,
|
||||
"force": true,
|
||||
}
|
||||
|
||||
// ✅ AJOUTER POSITION DANS LA QUEUE (si disponible)
|
||||
if livreurAssign != "" {
|
||||
position, posErr := database.GetCommandPositionInQueue(livreurAssign, commandID)
|
||||
if posErr == nil && position > 0 {
|
||||
response["details"].(gin.H)["position_in_queue"] = position
|
||||
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
|
||||
if queueInfo != nil {
|
||||
response["details"].(gin.H)["queue_info"] = queueInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("⚠️ [CANCEL_CLIENT] Confirmation requise pour cmd %d - hasETA=%v, penalty=%d",
|
||||
commandID, hasETA, nextPenalty)
|
||||
|
||||
c.JSON(http.StatusConflict, response)
|
||||
return
|
||||
}
|
||||
|
||||
switch err.Error() {
|
||||
case "commande non trouvée":
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
|
||||
case "commande ne vous appartient pas":
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous appartient pas"})
|
||||
|
||||
case "impossible d'annuler":
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cette commande ne peut plus être annulée"})
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible d'annuler la commande"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"message": "Commande annulée avec succès",
|
||||
"command_id": commandID,
|
||||
"new_status": "cancelled",
|
||||
}
|
||||
|
||||
if penalty > 0 {
|
||||
response["penalty"] = gin.H{
|
||||
"penalty_amount": penalty,
|
||||
"warning": "Une amende a été appliquée pour annulation tardive",
|
||||
}
|
||||
} else {
|
||||
response["info"] = "Aucune pénalité appliquée"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func GetMyCancellationHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil || c.GetString("role") != "client" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📊 [CANCEL_HISTORY] Client %s - Consultation historique", username)
|
||||
|
||||
history, err := database.GetClientCancellationHistory(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CANCEL_HISTORY] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var penaltyResult struct {
|
||||
Amende int `gorm:"column:amende"`
|
||||
}
|
||||
database.GDB.Raw(`SELECT COALESCE(amende, 0) as amende FROM clients WHERE username = ?`,
|
||||
username).Scan(&penaltyResult)
|
||||
totalPenalty := penaltyResult.Amende
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"username": username,
|
||||
"history": history,
|
||||
"total_penalties": totalPenalty,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllCancelledOrders(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
log.Printf("❌ [GET_ALL_CANCELLED] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
filterUsername := c.Query("username")
|
||||
if len(filterUsername) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username trop long"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.DefaultQuery("limit", "50")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit < 1 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
log.Printf("📋 [GET_ALL_CANCELLED] %s (%s) récupère %d commandes", username, userRole, limit)
|
||||
|
||||
cancelledOrders, err := database.GetCancelledCommands(filterUsername, limit)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_CANCELLED] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var enrichedOrders []map[string]any
|
||||
for _, order := range cancelledOrders {
|
||||
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
|
||||
|
||||
items, _ := database.GetCommandItems(orderID)
|
||||
logs, _ := database.GetCommandLogs(orderID)
|
||||
|
||||
var cancellationLog map[string]any
|
||||
for _, logEntry := range logs {
|
||||
status, _ := logEntry["status"].(string)
|
||||
if status == "cancelled" {
|
||||
cancellationLog = logEntry
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cancelReason, _ := order["cancel_reason"].(string)
|
||||
enrichedOrder := map[string]any{
|
||||
"id": order["id"],
|
||||
"username": order["username"],
|
||||
"total_prix": order["total_prix"],
|
||||
"created_at": order["created_at"],
|
||||
"updated_at": order["updated_at"],
|
||||
"items_count": len(items),
|
||||
"cancel_reason": cancelReason,
|
||||
}
|
||||
|
||||
if cancellationLog != nil {
|
||||
enrichedOrder["cancellation"] = gin.H{
|
||||
"cancelled_at": cancellationLog["created_at"],
|
||||
"cancelled_by": cancellationLog["author"],
|
||||
"reason": cancelReason,
|
||||
}
|
||||
}
|
||||
|
||||
enrichedOrders = append(enrichedOrders, enrichedOrder)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_CANCELLED] %d commandes récupérées", len(enrichedOrders))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"cancelled_orders": enrichedOrders,
|
||||
"count": len(enrichedOrders),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteCommandByCabine(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
log.Printf("❌ [DELETE_COMMAND] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux cabines"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || commandID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🗑️ [DELETE_COMMAND] %s (%s) supprime cmd %d", username, userRole, commandID)
|
||||
|
||||
err = database.DeleteCommandAtomic(commandID, username, userRole)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_COMMAND] Erreur: %v", err)
|
||||
|
||||
if err.Error() == "commande non trouvée" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la suppression"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DELETE_COMMAND] Commande %d supprimée", commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande supprimée définitivement",
|
||||
"deleted_by": gin.H{
|
||||
"username": username,
|
||||
"role": userRole,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetCategories(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
categories, err := database.GetAllCategories()
|
||||
if err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération catégories"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"categories": categories,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
IsComingSoon bool `json:"is_coming_soon"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if len(name) < 2 || len(name) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.ValidateCategoryColor(req.Color); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Couleur invalide (format hex requis, ex: #ff0000)"})
|
||||
return
|
||||
}
|
||||
|
||||
category, err := database.CreateCategory(name, req.Color, req.IsComingSoon)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Création erreur: %v", err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Cette catégorie existe déjà"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CATEGORIES] Créée: %s (couleur: %s)", name, category.Color)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"category": category,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/v2/admin/protected/categories/:id — admin
|
||||
func UpdateCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
IsComingSoon bool `json:"is_coming_soon"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if len(name) < 2 || len(name) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.ValidateCategoryColor(req.Color); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Couleur invalide (format hex requis, ex: #ff0000)"})
|
||||
return
|
||||
}
|
||||
|
||||
category, err := database.UpdateCategory(id, name, req.Color, req.IsComingSoon)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Mise à jour erreur: %v", err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce nom existe déjà ou catégorie introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CATEGORIES] Mise à jour: %d → %s (couleur: %s)", id, name, category.Color)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"category": category,
|
||||
})
|
||||
}
|
||||
|
||||
func ReorderCategories(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
IDs []int `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Liste d'IDs requise"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.ReorderCategories(req.IDs); err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Reorder erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du réordonnancement"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func DeleteCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteCategory(id); err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Suppression erreur: %v", err)
|
||||
if strings.Contains(err.Error(), "utilisée par") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Catégorie utilisée par des produits existants"})
|
||||
} else {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Catégorie non trouvée"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Catégorie supprimée"})
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetCommandStatus - Statut temps réel d'une commande
|
||||
func GetCommandStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
if cmdUsername != usernameStr {
|
||||
log.Printf("❌ [STATUS] Accès refusé - cmd de %s demandée par %s", cmdUsername, usernameStr)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous appartient pas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
livreurInfo := gin.H{
|
||||
"assigned": false,
|
||||
}
|
||||
if livreurAssign, ok := command["livreur_assign"].(string); ok && livreurAssign != "" {
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
|
||||
livreurInfo = gin.H{
|
||||
"assigned": true,
|
||||
"livreur_name": livreurAssign,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
}
|
||||
}
|
||||
|
||||
statusMessage := getStatusMessage(command["status"].(string))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": command["status"],
|
||||
"status_message": statusMessage,
|
||||
"adresse": command["adresse"],
|
||||
"total_prix": command["total_prix"],
|
||||
"created_at": command["created_at"],
|
||||
"livreur": livreurInfo,
|
||||
"eta": etaData,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyCommandsWithTracking - Liste des commandes avec suivi
|
||||
func GetMyCommandsWithTracking(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
status := c.Query("status")
|
||||
|
||||
log.Printf("📋 [MY_CMDS] Client %s demande ses commandes (status=%s)", usernameStr, status)
|
||||
|
||||
commands, err := database.GetAllCommands(status, usernameStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Enrichir avec tracking
|
||||
enrichedCommands := make([]gin.H, len(commands))
|
||||
for i, cmd := range commands {
|
||||
commandID, _ := cmd["id"].(int)
|
||||
|
||||
// ETA
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
// Infos livreur
|
||||
livreurInfo := gin.H{
|
||||
"assigned": false,
|
||||
}
|
||||
if livreurAssign, ok := cmd["livreur_assign"].(string); ok && livreurAssign != "" {
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
|
||||
livreurInfo = gin.H{
|
||||
"assigned": true,
|
||||
"livreur_name": livreurAssign,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
}
|
||||
}
|
||||
|
||||
enrichedCommands[i] = gin.H{
|
||||
"id": cmd["id"],
|
||||
"client_order_number": cmd["client_order_number"],
|
||||
"status": cmd["status"],
|
||||
"status_message": getStatusMessage(cmd["status"].(string)),
|
||||
"adresse": cmd["adresse"],
|
||||
"total_prix": cmd["total_prix"],
|
||||
"referral_used": cmd["referral_used"],
|
||||
"created_at": cmd["created_at"],
|
||||
"livreur": livreurInfo,
|
||||
"eta": etaData,
|
||||
"proposed_address": cmd["proposed_address"],
|
||||
"address_proposal_status": cmd["address_proposal_status"],
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"commands": enrichedCommands,
|
||||
"count": len(enrichedCommands),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommandTracking - Suivi détaillé d'une commande
|
||||
func GetCommandTracking(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username := c.GetString("username")
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier propriété
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
if cmdUsername != username {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous appartient pas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logs, _ := database.GetCommandLogs(commandID)
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
timeline := buildTimeline(logs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": command["status"],
|
||||
"status_message": getStatusMessage(command["status"].(string)),
|
||||
"eta": etaData,
|
||||
"timeline": timeline,
|
||||
"logs": logs,
|
||||
})
|
||||
}
|
||||
|
||||
func getStatusMessage(status string) string {
|
||||
messages := map[string]string{
|
||||
"pending": "⏳ En attente d'assignation",
|
||||
"assigned": "✅ Livreur assigné",
|
||||
"en_route": "🚗 En cours de livraison",
|
||||
"arrived": "📍 Livreur arrivé",
|
||||
"livre": "📦 Livré - En attente de confirmation",
|
||||
"delivered": "✅ Livré",
|
||||
"approved": "🎉 Livraison confirmée",
|
||||
"cancelled": "🚫 Annulée",
|
||||
"disabled": "⚠️ Désactivée",
|
||||
}
|
||||
|
||||
if msg, ok := messages[status]; ok {
|
||||
return msg
|
||||
}
|
||||
return "📋 " + status
|
||||
}
|
||||
|
||||
// buildTimeline construit une timeline depuis les logs
|
||||
func buildTimeline(logs []map[string]any) []gin.H {
|
||||
timeline := make([]gin.H, 0)
|
||||
|
||||
for _, logEntry := range logs {
|
||||
status, _ := logEntry["status"].(string)
|
||||
message, _ := logEntry["message"].(string)
|
||||
createdAt := logEntry["created_at"]
|
||||
|
||||
timeline = append(timeline, gin.H{
|
||||
"status": status,
|
||||
"message": message,
|
||||
"icon": getStatusIcon(status),
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
return timeline
|
||||
}
|
||||
|
||||
// getStatusIcon retourne une icône pour la timeline
|
||||
func getStatusIcon(status string) string {
|
||||
icons := map[string]string{
|
||||
"created": "🛒",
|
||||
"assigned": "👤",
|
||||
"en_route": "🚗",
|
||||
"arrived": "📍",
|
||||
"livre": "✅",
|
||||
"approved": "🎉",
|
||||
"cancelled": "🚫",
|
||||
}
|
||||
|
||||
if icon, ok := icons[status]; ok {
|
||||
return icon
|
||||
}
|
||||
return "📋"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,131 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// IPNWebhook - POST /api/v1/webhooks/nowpayments
|
||||
func IPNWebhook(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
npRaw, npExists := c.Get("nowpayments")
|
||||
np, ok := npRaw.(*services.NowPaymentsClient)
|
||||
if !npExists || !ok || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "impossible de lire le corps"})
|
||||
return
|
||||
}
|
||||
|
||||
sig := c.GetHeader("x-nowpayments-sig")
|
||||
if !np.VerifyIPN(body, sig) {
|
||||
log.Printf("[IPN] signature invalide")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "signature invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload services.IPNPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "payload invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
payment, err := database.GetCryptoPaymentByNowPaymentID(payload.PaymentID.String())
|
||||
if err != nil || payment == nil {
|
||||
log.Printf("[IPN] paiement introuvable: %s", payload.PaymentID.String())
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
|
||||
payAmount, _ := payload.PayAmount.Float64()
|
||||
if err := database.UpdateCryptoPaymentStatus(payment.ID, payload.PaymentStatus, payAmount); err != nil {
|
||||
log.Printf("[IPN] erreur mise à jour paiement %d: %v", payment.ID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur base de données"})
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.PaymentStatus {
|
||||
case "finished", "confirmed":
|
||||
if err := database.ActivateCryptoCommand(payment.CommandID); err != nil {
|
||||
log.Printf("[IPN] erreur activation commande %d: %v", payment.CommandID, err)
|
||||
} else {
|
||||
log.Printf("[IPN] commande %d activée (paiement %s confirmé)", payment.CommandID, payload.PaymentID.String())
|
||||
}
|
||||
case "failed", "expired":
|
||||
if err := database.CancelCryptoCommand(payment.CommandID); err != nil {
|
||||
log.Printf("[IPN] erreur annulation commande %d: %v", payment.CommandID, err)
|
||||
} else {
|
||||
log.Printf("[IPN] commande %d annulée (paiement %s)", payment.CommandID, payload.PaymentStatus)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GetCommandPaymentStatus - GET /api/v1/commands/:id/payment-status
|
||||
// Retourne le statut du paiement crypto d'une commande (polling côté client)
|
||||
// Effectue un refresh temps réel depuis NowPayments si le paiement est encore en attente
|
||||
func GetCommandPaymentStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
payment, err := database.GetCryptoPaymentByCommandID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur base de données"})
|
||||
return
|
||||
}
|
||||
if payment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "aucun paiement crypto pour cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh temps réel depuis NowPayments pour les statuts intermédiaires (comme la référence)
|
||||
switch payment.Status {
|
||||
case "waiting", "confirming", "confirmed", "sending":
|
||||
np, npOk := c.Get("nowpayments")
|
||||
if npOk && np != nil {
|
||||
npClient := np.(*services.NowPaymentsClient)
|
||||
npStatus, err := npClient.GetPaymentStatus(payment.NowPaymentID)
|
||||
if err == nil && npStatus.PaymentStatus != payment.Status {
|
||||
payAmount, _ := npStatus.PayAmount.Float64()
|
||||
if updateErr := database.UpdateCryptoPaymentStatus(payment.ID, npStatus.PaymentStatus, payAmount); updateErr == nil {
|
||||
payment.Status = npStatus.PaymentStatus
|
||||
payment.PayAmount = payAmount
|
||||
log.Printf("[PAYMENT-STATUS] cmd %d: %s → %s (refresh temps réel)", commandID, payment.Status, npStatus.PaymentStatus)
|
||||
}
|
||||
switch npStatus.PaymentStatus {
|
||||
case "finished", "confirmed":
|
||||
_ = database.ActivateCryptoCommand(commandID)
|
||||
case "failed", "expired":
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"command_id": payment.CommandID,
|
||||
"payment_status": payment.Status,
|
||||
"pay_address": payment.PayAddress,
|
||||
"pay_amount": payment.PayAmount,
|
||||
"pay_currency": payment.PayCurrency,
|
||||
"price_amount": payment.PriceAmount,
|
||||
"price_currency": payment.PriceCurrency,
|
||||
})
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetMyDeliveries(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
status := c.Query("status")
|
||||
|
||||
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Collecter tous les IDs et usernames en une passe pour éviter les N+1
|
||||
commandIDs := make([]int, 0, len(commands))
|
||||
clientUsernames := make([]string, 0, len(commands))
|
||||
for _, cmd := range commands {
|
||||
if cid, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])); cid > 0 {
|
||||
commandIDs = append(commandIDs, cid)
|
||||
}
|
||||
if u, _ := cmd["username"].(string); u != "" {
|
||||
clientUsernames = append(clientUsernames, u)
|
||||
}
|
||||
}
|
||||
allItems, _ := database.GetCommandItemsBatch(commandIDs)
|
||||
allClients, _ := database.GetClientsByUsernames(clientUsernames)
|
||||
|
||||
filteredCommands := make([]gin.H, len(commands))
|
||||
for i, cmd := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
|
||||
items := allItems[commandID]
|
||||
|
||||
clientUsername, _ := cmd["username"].(string)
|
||||
client := allClients[clientUsername]
|
||||
|
||||
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
||||
if client != nil {
|
||||
clientInfo = gin.H{
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
}
|
||||
}
|
||||
|
||||
itemsSummary := make([]gin.H, len(items))
|
||||
for j, item := range items {
|
||||
itemsSummary[j] = gin.H{
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"is_reward": item["is_reward"],
|
||||
}
|
||||
}
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
filteredCommands[i] = gin.H{
|
||||
"id": cmd["id"],
|
||||
"status": cmd["status"],
|
||||
"adresse": cmd["adresse"],
|
||||
"total_prix": cmd["total_prix"],
|
||||
"referral_used": cmd["referral_used"],
|
||||
"created_at": cmd["created_at"],
|
||||
"client_info": clientInfo,
|
||||
"items": itemsSummary,
|
||||
"items_count": len(items),
|
||||
"eta": etaData,
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [MY_DELIVERIES] %d livraisons (données filtrées)", len(filteredCommands))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"deliveries": filteredCommands,
|
||||
"count": len(filteredCommands),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GetDeliveryDetails
|
||||
// ============================================
|
||||
func GetDeliveryDetails(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username := c.GetString("username")
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER PROPRIÉTÉ
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username {
|
||||
log.Printf("❌ Accès refusé - cmd assignée à %s", livreurAssign)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette livraison ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
items, _ := database.GetCommandItems(commandID)
|
||||
|
||||
clientUsername, _ := command["username"].(string)
|
||||
client, _ := database.GetClientByUsername(clientUsername)
|
||||
|
||||
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
||||
if client != nil {
|
||||
clientInfo = gin.H{
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
}
|
||||
}
|
||||
|
||||
itemsSummary := make([]gin.H, len(items))
|
||||
for i, item := range items {
|
||||
itemsSummary[i] = gin.H{
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"is_reward": item["is_reward"],
|
||||
}
|
||||
}
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"delivery": gin.H{
|
||||
"id": command["id"],
|
||||
"status": command["status"],
|
||||
"adresse": command["adresse"],
|
||||
"total_prix": command["total_prix"],
|
||||
"referral_used": command["referral_used"],
|
||||
"created_at": command["created_at"],
|
||||
"client_info": clientInfo,
|
||||
"items": itemsSummary,
|
||||
"items_count": len(items),
|
||||
"eta": etaData,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateDeliveryStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists || c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Notes string `json:"notes"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📝 [UPD_STATUS] %s update cmd %d: %s", usernameStr, commandID, req.Status)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != usernameStr {
|
||||
log.Printf("❌ Accès refusé - assigné à %s", livreurAssign)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := []string{
|
||||
"assigned",
|
||||
"en_route",
|
||||
"arrived",
|
||||
"livre",
|
||||
"cancelled",
|
||||
}
|
||||
|
||||
if !slices.Contains(validStatuses, req.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut invalide",
|
||||
"valid_statuses": validStatuses,
|
||||
"received": req.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "livre" {
|
||||
if req.Latitude == 0 || req.Longitude == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Coordonnées GPS requises pour confirmer la livraison"})
|
||||
return
|
||||
}
|
||||
|
||||
destLat, _ := command["dest_latitude"].(float64)
|
||||
destLon, _ := command["dest_longitude"].(float64)
|
||||
|
||||
if destLat != 0 && destLon != 0 {
|
||||
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
|
||||
log.Printf("📍 [GPS] Distance: %.2f m", distance)
|
||||
|
||||
log.Printf("✅ [GPS] Validation OK")
|
||||
} else {
|
||||
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le statut.
|
||||
// Le cas "cancelled" passe par une transaction atomique dédiée (transition +
|
||||
// remboursement stock), pour empêcher tout double remboursement en cas de
|
||||
// double appel (double-tap, retry réseau, commande déjà annulée ailleurs).
|
||||
if req.Status == "cancelled" {
|
||||
alreadyCancelled, prevStatus, cancelErr := database.CancelDeliveryByLivreurAtomic(commandID)
|
||||
if cancelErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
if alreadyCancelled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande déjà annulée",
|
||||
"command_id": commandID,
|
||||
"status": "cancelled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cancelMsg := req.Notes
|
||||
if cancelMsg == "" {
|
||||
cancelMsg = "Annulé par le livreur"
|
||||
}
|
||||
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
|
||||
|
||||
if prevStatus == "arrived" || prevStatus == "livre" {
|
||||
clientUsername, _ := command["username"].(string)
|
||||
if clientUsername != "" {
|
||||
if penalty, err := database.ApplyCancellationPenalty(clientUsername); err == nil {
|
||||
log.Printf("⚠️ [CANCEL_LIVREUR] Amende %d appliquée à %s (client absent)", penalty, clientUsername)
|
||||
} else {
|
||||
log.Printf("⚠️ [CANCEL_LIVREUR] Erreur application amende pour %s: %v", clientUsername, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
|
||||
var etaMinutes int
|
||||
var etaMessage string
|
||||
|
||||
if req.Status == "en_route" {
|
||||
log.Printf("🚗 [STATUS_LIVREUR] Passage en 'en_route' - Calcul ETA...")
|
||||
|
||||
var destLat, destLon float64
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
|
||||
if err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||
destLat = coords.Lat
|
||||
destLon = coords.Lon
|
||||
log.Printf("📍 [STATUS_LIVREUR] Coords depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: récupérer depuis la DB
|
||||
if destLat == 0 || destLon == 0 {
|
||||
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
|
||||
destLat = dLat
|
||||
}
|
||||
if dLon, ok := command["dest_longitude"].(float64); ok && dLon != 0 {
|
||||
destLon = dLon
|
||||
}
|
||||
if destLat != 0 && destLon != 0 {
|
||||
log.Printf("📍 [STATUS_LIVREUR] Coords depuis DB: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
if destLat != 0 && destLon != 0 {
|
||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
||||
|
||||
// Cas 1 : GPS du livreur disponible
|
||||
gpsLat, gpsLon, gpsErr := database.GetDeliveryPersonLocation(usernameStr)
|
||||
if gpsErr == nil && gpsLat != 0 {
|
||||
from := services.Coordinates{Latitude: gpsLat, Longitude: gpsLon}
|
||||
eta, _, err := services.GetETAWithTraffic(from, toCoords)
|
||||
if err != nil {
|
||||
eta = services.CalculateETA(services.CalculateDistance(from, toCoords))
|
||||
}
|
||||
etaMinutes = eta
|
||||
log.Printf("📍 [STATUS_LIVREUR] ETA depuis GPS livreur: %d min", etaMinutes)
|
||||
} else {
|
||||
// Cas 2 : GPS absent → dernière adresse de livraison
|
||||
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(usernameStr)
|
||||
if lastErr == nil && lastLat != 0 {
|
||||
from := services.Coordinates{Latitude: lastLat, Longitude: lastLon}
|
||||
eta, _, err := services.GetETAWithTraffic(from, toCoords)
|
||||
if err != nil {
|
||||
eta = services.CalculateETA(services.CalculateDistance(from, toCoords))
|
||||
}
|
||||
etaMinutes = eta
|
||||
log.Printf("📍 [STATUS_LIVREUR] ETA depuis dernière livraison: %d min", etaMinutes)
|
||||
} else {
|
||||
// Cas 3 : Aucune position disponible
|
||||
etaMinutes = 30
|
||||
log.Printf("⚠️ [STATUS_LIVREUR] Aucune position disponible - ETA par défaut: %d min", etaMinutes)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
etaMinutes = 30
|
||||
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut: %d min", etaMinutes)
|
||||
}
|
||||
|
||||
database.SetCommandETA(commandID, etaMinutes)
|
||||
log.Printf("✅ [STATUS_LIVREUR] ETA défini: %d minutes", etaMinutes)
|
||||
|
||||
if etaMinutes >= 60 {
|
||||
h := etaMinutes / 60
|
||||
m := etaMinutes % 60
|
||||
if m > 0 {
|
||||
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh%02d", h, m)
|
||||
} else {
|
||||
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh", h)
|
||||
}
|
||||
} else {
|
||||
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
|
||||
}
|
||||
|
||||
// Mettre à jour le statut du livreur en "delivering"
|
||||
database.SetDeliveryPersonStatus(usernameStr, "delivering", commandID)
|
||||
log.Printf("🚗 [STATUS_LIVREUR] Statut livreur mis à jour: delivering")
|
||||
}
|
||||
|
||||
// Log
|
||||
message := req.Notes
|
||||
if message == "" {
|
||||
message = utils.GetDeliveryStatusMessage(req.Status)
|
||||
}
|
||||
if etaMessage != "" {
|
||||
message += fmt.Sprintf(" - %s", etaMessage)
|
||||
}
|
||||
database.AddCommandLog(commandID, req.Status, message, usernameStr)
|
||||
|
||||
// ✅ NOTIFICATION CLIENT
|
||||
clientUsername, _ := command["username"].(string)
|
||||
if clientUsername != "" {
|
||||
var clientMsg string
|
||||
switch req.Status {
|
||||
case "en_route":
|
||||
notifETA := etaMinutes
|
||||
if notifETA == 0 {
|
||||
if etaData, err := database.GetCommandETA(commandID); err == nil {
|
||||
if v, ok := etaData["total_eta_minutes"]; ok {
|
||||
if n, err2 := strconv.Atoi(v); err2 == nil && n > 0 {
|
||||
notifETA = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if notifETA > 0 {
|
||||
var etaStr string
|
||||
if notifETA >= 60 {
|
||||
h := notifETA / 60
|
||||
m := notifETA % 60
|
||||
if m > 0 {
|
||||
etaStr = fmt.Sprintf("%dh%02d", h, m)
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%dh", h)
|
||||
}
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%d min", notifETA)
|
||||
}
|
||||
clientMsg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route (~%s)", database.GetClientOrderID(commandID), etaStr)
|
||||
} else {
|
||||
clientMsg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route.", database.GetClientOrderID(commandID))
|
||||
}
|
||||
case "arrived":
|
||||
clientMsg = fmt.Sprintf("Descend, le livreur est là dans 3min (commande #%d) 🛵", database.GetClientOrderID(commandID))
|
||||
case "livre":
|
||||
clientMsg = fmt.Sprintf("Ta commande #%d a bien été livrée ! Bonne dégustation l'ami et à bientôt 😊\n\n<b>⚠️ VALIDE LA RÉCEPTION DE TA COMMANDE DANS LA RUBRIQUE SUIVI POUR RÉCUPÉRER TES POINTS DE FIDÉLITÉ ⚠️</b>", database.GetClientOrderID(commandID))
|
||||
case "cancelled":
|
||||
clientMsg = fmt.Sprintf("Votre commande #%d a été annulée par le livreur", database.GetClientOrderID(commandID))
|
||||
}
|
||||
if clientMsg != "" {
|
||||
database.NotifyClient(clientUsername, commandID, req.Status, clientMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ GESTION SPÉCIALE SELON LE STATUT
|
||||
switch req.Status {
|
||||
case "livre":
|
||||
// Livraison terminée - Optimiser la queue
|
||||
log.Printf("📦 Livraison marquée 'livre' - Optimisation queue...")
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "cancelled":
|
||||
// Transition + remboursement stock déjà effectués atomiquement plus haut.
|
||||
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "arrived":
|
||||
log.Printf("📍 Livreur arrivé à destination - Commande %d", commandID)
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"message": "Statut mis à jour",
|
||||
"command_id": commandID,
|
||||
"status": req.Status,
|
||||
}
|
||||
|
||||
if req.Status == "en_route" && etaMinutes > 0 {
|
||||
response["eta_minutes"] = etaMinutes
|
||||
response["eta_message"] = etaMessage
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// POST /api/v1/livreur/deliveries/:id/issue
|
||||
func ReportDeliveryIssue(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IssueType string `json:"issue_type" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "issue_type requis"})
|
||||
return
|
||||
}
|
||||
|
||||
validTypes := map[string]bool{
|
||||
"client_absent": true,
|
||||
"wrong_address": true,
|
||||
"refused_delivery": true,
|
||||
"no_access": true,
|
||||
"other": true,
|
||||
}
|
||||
if !validTypes[req.IssueType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de problème invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable"})
|
||||
return
|
||||
}
|
||||
if livreur, _ := command["livreur_assign"].(string); livreur != username {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non assignée à vous"})
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := database.CreateDeliveryIssue(commandID, req.IssueType, req.Description, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ISSUE] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création problème"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
|
||||
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
|
||||
}
|
||||
|
||||
func GetMyDeliveryStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
usernameStr := username.(string)
|
||||
|
||||
var dayRows []models.DayRowWithResult
|
||||
if err := database.GetMyDeliveryStatsPerDay(&dayRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats jour"})
|
||||
return
|
||||
}
|
||||
|
||||
var weekRows []models.WeekRow
|
||||
if err := database.GetMyDeliveryStatsPerWeek(&weekRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats semaine"})
|
||||
return
|
||||
}
|
||||
|
||||
var monthRows []models.MonthRow
|
||||
if err := database.GetMyDeliveryStatsPerMonth(&monthRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats mois"})
|
||||
return
|
||||
}
|
||||
|
||||
var todayRow models.TodayRow
|
||||
if err := database.GetMyDeliveryStatsToday(&todayRow, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats du jour"})
|
||||
return
|
||||
}
|
||||
|
||||
monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"}
|
||||
|
||||
byDay := make([]gin.H, len(dayRows))
|
||||
for i, r := range dayRows {
|
||||
byDay[i] = gin.H{
|
||||
"label": r.Day.Format("02/01"),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
byWeek := make([]gin.H, len(weekRows))
|
||||
for i, r := range weekRows {
|
||||
byWeek[i] = gin.H{
|
||||
"label": fmt.Sprintf("S%d", r.WeekNum),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
byMonth := make([]gin.H, len(monthRows))
|
||||
for i, r := range monthRows {
|
||||
label := "?"
|
||||
if r.MonthNum >= 1 && r.MonthNum <= 12 {
|
||||
label = monthNames[r.MonthNum]
|
||||
}
|
||||
byMonth[i] = gin.H{
|
||||
"label": label,
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"by_day": byDay,
|
||||
"by_week": byWeek,
|
||||
"by_month": byMonth,
|
||||
"today_count": todayRow.Count,
|
||||
"today_revenue": todayRow.Revenue,
|
||||
})
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
// ============================================
|
||||
// handlers/delivery_admin_handlers.go
|
||||
// HANDLERS ADMIN POUR LA GESTION DES LIVREURS
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetDeliveryPersonDetails récupère les détails complets d'un livreur
|
||||
func GetDeliveryPersonDetails(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Livreur non trouvé: %v", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Livreur non trouvé",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que c'est bien un livreur
|
||||
if livreur.Role != "livreur" {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Utilisateur n'est pas un livreur: role=%s", livreur.Role)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
"role": livreur.Role,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status, err := database.GetDeliveryPersonStatus(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GET_DELIVERY_DETAILS] Impossible de récupérer le statut: %v", err)
|
||||
status = "offline" // Statut par défaut
|
||||
}
|
||||
|
||||
// Utiliser la fonction GPS existante
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
var locationInfo map[string]any
|
||||
if err == nil {
|
||||
locationInfo = map[string]any{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
}
|
||||
}
|
||||
|
||||
queueSize, _ := database.GetDeliverymanQueueSize(username)
|
||||
currentCommand, _ := database.GetCurrentCommand(username)
|
||||
|
||||
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
|
||||
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
|
||||
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "assigned,en_route,livre")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"deliveryman": gin.H{
|
||||
"id": livreur.ID,
|
||||
"username": livreur.Username,
|
||||
"role": livreur.Role,
|
||||
"status": status,
|
||||
"current_command": currentCommand,
|
||||
"queue_size": queueSize,
|
||||
"total_deliveries": totalDeliveries,
|
||||
"completed_deliveries": completedDeliveries,
|
||||
"pending_deliveries": pendingDeliveries,
|
||||
"location": locationInfo,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDeliveryPersonStatusAdmin modifie le statut d'un livreur (Admin)
|
||||
func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if !utils.CheckRoleAdmin(c, userRole) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut requis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := []string{"available", "busy", "offline"}
|
||||
|
||||
if !slices.Contains(validStatuses, req.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut invalide",
|
||||
"valid_statuses": validStatuses,
|
||||
"received": req.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📝 [UPDATE_DELIVERY_STATUS] Modification: %s → %s", username, req.Status)
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.UpdateDeliveryPersonStatus(username, req.Status)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour statut",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
log.Printf("✅ [UPDATE_DELIVERY_STATUS] Statut modifié par admin %s: %s → %s",
|
||||
adminUsername, username, req.Status)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Statut du livreur mis à jour",
|
||||
"username": username,
|
||||
"new_status": req.Status,
|
||||
"updated_by": adminUsername,
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliveryPersonStats récupère les statistiques d'un livreur
|
||||
func GetDeliveryPersonStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
log.Printf("❌ [GET_DELIVERY_STATS] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📊 [GET_DELIVERY_STATS] Calcul stats pour: %s", username)
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_STATS] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Récupérer les statistiques
|
||||
// ============================================
|
||||
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
|
||||
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
|
||||
cancelledDeliveries, _ := database.CountDeliveriesByStatus(username, "cancelled")
|
||||
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "pending,assigned")
|
||||
inProgressDeliveries, _ := database.CountDeliveriesByStatus(username, "en_route,livre")
|
||||
|
||||
// Récupérer statut et queue
|
||||
status, _ := database.GetDeliveryPersonStatus(username)
|
||||
queueSize, _ := database.GetDeliverymanQueueSize(username)
|
||||
|
||||
// Calculer le taux de succès
|
||||
successRate := 0.0
|
||||
if totalDeliveries > 0 {
|
||||
successRate = (float64(completedDeliveries) / float64(totalDeliveries)) * 100
|
||||
}
|
||||
|
||||
// Récupérer la dernière livraison
|
||||
lastDeliveryDate := ""
|
||||
lastDelivery, err := database.GetLastDeliveryDate(username)
|
||||
if err == nil && lastDelivery != nil {
|
||||
lastDeliveryDate = lastDelivery.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_DELIVERY_STATS] Stats calculées: total=%d, completed=%d",
|
||||
totalDeliveries, completedDeliveries)
|
||||
|
||||
// ============================================
|
||||
// RÉPONSE
|
||||
// ============================================
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"stats": gin.H{
|
||||
"username": username,
|
||||
"total_deliveries": totalDeliveries,
|
||||
"completed_deliveries": completedDeliveries,
|
||||
"cancelled_deliveries": cancelledDeliveries,
|
||||
"pending_deliveries": pendingDeliveries,
|
||||
"in_progress_deliveries": inProgressDeliveries,
|
||||
"success_rate": successRate,
|
||||
"current_queue_size": queueSize,
|
||||
"last_delivery_date": lastDeliveryDate,
|
||||
"status": status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliveryPersonHistory récupère l'historique des livraisons d'un livreur
|
||||
func GetDeliveryPersonHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
// Paramètres de pagination
|
||||
limit := 20
|
||||
offset := 0
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
if offsetStr := c.Query("offset"); offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
||||
offset = o
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📜 [GET_DELIVERY_HISTORY] Récupération historique: %s (limit=%d, offset=%d)",
|
||||
username, limit, offset)
|
||||
|
||||
// ============================================
|
||||
// Vérifier que le livreur existe
|
||||
// ============================================
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Récupérer l'historique
|
||||
// ============================================
|
||||
history, err := database.GetDeliveryPersonHistory(username, limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Erreur récupération: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Compter le total
|
||||
total, _ := database.CountDeliveriesByStatus(username, "")
|
||||
|
||||
log.Printf("✅ [GET_DELIVERY_HISTORY] Historique récupéré: %d livraisons (total=%d)",
|
||||
len(history), total)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"history": history,
|
||||
"count": len(history),
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDeliveryPersonLocationAdmin modifie la position GPS d'un livreur (Admin)
|
||||
// PUT /api/v2/admin/protected/delivery-persons/:username/location
|
||||
func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude" binding:"required"`
|
||||
Longitude float64 `json:"longitude" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Valider les coordonnées
|
||||
if req.Latitude < -90 || req.Latitude > 90 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Latitude invalide (doit être entre -90 et 90)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Longitude < -180 || req.Longitude > 180 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Longitude invalide (doit être entre -180 et 180)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 [UPDATE_DELIVERY_LOCATION] Modification: %s → (%.6f, %.6f)",
|
||||
username, req.Latitude, req.Longitude)
|
||||
|
||||
// ============================================
|
||||
// Vérifier que le livreur existe
|
||||
// ============================================
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mettre à jour la position (utilise la fonction existante)
|
||||
// ============================================
|
||||
err = database.UpdateDeliveryPersonLocation(username, req.Latitude, req.Longitude)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour position",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
log.Printf("✅ [UPDATE_DELIVERY_LOCATION] Position modifiée par admin %s: %s → (%.6f, %.6f)",
|
||||
adminUsername, username, req.Latitude, req.Longitude)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Position GPS mise à jour",
|
||||
"location": gin.H{
|
||||
"username": username,
|
||||
"latitude": req.Latitude,
|
||||
"longitude": req.Longitude,
|
||||
"updated_at": time.Now().Unix(),
|
||||
},
|
||||
"updated_by": adminUsername,
|
||||
})
|
||||
}
|
||||
|
||||
func RemoveCommandFromQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
commandIDStr := c.Param("command_id")
|
||||
|
||||
if username == "" || commandIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Username et command_id requis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(commandIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "ID de commande invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🗑️ [REMOVE_FROM_QUEUE] Suppression: cmd %d de la queue de %s", commandID, username)
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur suppression de la queue",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
currentStatus, _ := command["status"].(string)
|
||||
if currentStatus == "assigned" || currentStatus == "en_route" {
|
||||
err = database.UpdateCommandStatus(commandID, "pending")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [REMOVE_FROM_QUEUE] Impossible de réinitialiser le statut: %v", err)
|
||||
} else {
|
||||
database.UpdateCommandLivreur(commandID, "")
|
||||
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande réinitialisée en 'pending'")
|
||||
}
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
database.AddCommandLog(commandID, "queue_removed",
|
||||
fmt.Sprintf("Commande retirée de la queue du livreur %s par admin %s", username, adminUsername),
|
||||
adminUsername.(string))
|
||||
|
||||
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande %d retirée de la queue de %s", commandID, username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande retirée de la queue du livreur",
|
||||
"command_id": commandID,
|
||||
"username": username,
|
||||
"removed_by": adminUsername,
|
||||
})
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// returnStaleOrUnavailable retourne le cache périmé avec le temps restant recalculé,
|
||||
// ou {eta_available: false, message: "Aucune heure disponible"} si le cache est absent ou expiré.
|
||||
func returnStaleOrUnavailable(commandID int, status string, etaData map[string]string) gin.H {
|
||||
if len(etaData) > 0 {
|
||||
if updatedAtStr, ok := etaData["updated_at"]; ok {
|
||||
var updatedAt int64
|
||||
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
|
||||
var etaMin int64
|
||||
if etaStr, ok2 := etaData["eta_minutes"]; ok2 {
|
||||
fmt.Sscanf(etaStr, "%d", &etaMin)
|
||||
}
|
||||
elapsed := int64(time.Since(time.Unix(updatedAt, 0)).Minutes())
|
||||
remaining := etaMin - elapsed
|
||||
if remaining > 0 {
|
||||
arrival := time.Now().Add(time.Duration(remaining) * time.Minute)
|
||||
log.Printf("📦 [ETA] Cache périmé utilisé - %d min restantes", remaining)
|
||||
return gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": status,
|
||||
"eta_minutes": remaining,
|
||||
"estimated_arrival": arrival.Format("15:04"),
|
||||
"eta_available": true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": status,
|
||||
"eta_available": false,
|
||||
"message": "Aucune heure disponible",
|
||||
}
|
||||
}
|
||||
|
||||
func GetOrderETA(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
// 1️⃣ AUTHENTIFICATION
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [ETA] Non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": "Utilisateur non authentifié",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2️⃣ RÉCUPÉRER L'ID DE LA COMMANDE
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
log.Printf("❌ [ETA] ID invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "ID de commande invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ETA] Commande %d non trouvée", commandID)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Commande non trouvée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
userRole := c.GetString("role")
|
||||
|
||||
if userRole != "admin" {
|
||||
if userRole == "client" && cmdUsername != username.(string) {
|
||||
log.Printf("❌ [ETA] Accès refusé - commande appartient à %s", cmdUsername)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"error": "Vous n'avez pas accès à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if userRole == "livreur" {
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username.(string) {
|
||||
log.Printf("❌ [ETA] Accès refusé - livreur non assigné")
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"error": "Vous n'avez pas accès à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmdStatus, _ := command["status"].(string)
|
||||
|
||||
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
|
||||
log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"message": "La commande a déjà été livrée",
|
||||
"eta_available": false,
|
||||
"estimated_arrival": "Livraison complétée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if cmdStatus == "pending" || cmdStatus == "assigned" {
|
||||
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_available": false,
|
||||
"message": "En attente de démarrage de la livraison",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if cmdStatus == "arrived" {
|
||||
log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_available": false,
|
||||
"message": "Le livreur est arrivé à destination",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
||||
|
||||
if err == nil && len(etaData) > 0 {
|
||||
// ETA existe, vérifier s'il est récent
|
||||
if updatedAtStr, ok := etaData["updated_at"]; ok {
|
||||
var updatedAt int64
|
||||
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
|
||||
|
||||
timeSinceUpdate := time.Since(time.Unix(updatedAt, 0))
|
||||
if timeSinceUpdate < 30*time.Second {
|
||||
// Cache valide
|
||||
var etaMinutes int64
|
||||
if etaStr, ok := etaData["eta_minutes"]; ok {
|
||||
fmt.Sscanf(etaStr, "%d", &etaMinutes)
|
||||
}
|
||||
|
||||
var arrivalTime int64
|
||||
if arrivalStr, ok := etaData["arrival_time"]; ok {
|
||||
fmt.Sscanf(arrivalStr, "%d", &arrivalTime)
|
||||
}
|
||||
|
||||
log.Printf("✅ [ETA] Cache hit - ETA: %d min", etaMinutes)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_minutes": etaMinutes,
|
||||
"estimated_arrival": time.Unix(arrivalTime, 0).Format("15:04"),
|
||||
"eta_available": true,
|
||||
"with_traffic": true,
|
||||
"livreur_assign": command["livreur_assign"],
|
||||
"delivery_address": command["adresse"],
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
|
||||
|
||||
var destLat, destLon float64
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
|
||||
if err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||
destLat = coords.Lat
|
||||
destLon = coords.Lon
|
||||
log.Printf("📍 Destination depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
if destLat == 0 || destLon == 0 {
|
||||
if dLat, okLat := command["dest_latitude"].(float64); okLat && dLat != 0 {
|
||||
destLat = dLat
|
||||
}
|
||||
if dLon, okLon := command["dest_longitude"].(float64); okLon && dLon != 0 {
|
||||
destLon = dLon
|
||||
}
|
||||
}
|
||||
|
||||
if destLat == 0 || destLon == 0 {
|
||||
log.Printf("⚠️ [ETA] Coordonnées destination manquantes - retour cache périmé ou message")
|
||||
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer position du livreur
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign == "" {
|
||||
log.Printf("⚠️ [ETA] Aucun livreur assigné")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_available": false,
|
||||
"message": "Aucune heure disponible",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
||||
|
||||
livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign)
|
||||
if gpsErr != nil {
|
||||
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign)
|
||||
if lastErr != nil || lastLat == 0 {
|
||||
log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign)
|
||||
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
|
||||
return
|
||||
}
|
||||
livreurLocation = &services.Coordinates{Latitude: lastLat, Longitude: lastLon}
|
||||
log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon)
|
||||
}
|
||||
|
||||
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
|
||||
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
|
||||
|
||||
etaMinutes, distanceKm, err := services.GetETAWithTraffic(*livreurLocation, toCoords)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ETA] TomTom failed, fallback local: %v", err)
|
||||
distanceKm = services.CalculateDistance(*livreurLocation, toCoords)
|
||||
etaMinutes = services.CalculateETA(distanceKm)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
|
||||
|
||||
etaCache := map[string]any{
|
||||
"command_id": commandID,
|
||||
"eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"distance_km": distanceKm,
|
||||
"with_traffic": err == nil,
|
||||
}
|
||||
|
||||
db.Redis.HSet(db.RedisCtx, etaKey, etaCache)
|
||||
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
|
||||
|
||||
log.Printf("✅ [ETA] SUCCESS - ETA: %d min, arrivée: %s", etaMinutes, arrivalTime.Format("15:04"))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_minutes": etaMinutes,
|
||||
"distance_km": fmt.Sprintf("%.2f", distanceKm),
|
||||
"estimated_arrival": arrivalTime.Format("15:04"),
|
||||
"eta_available": true,
|
||||
"with_traffic": err == nil,
|
||||
"livreur_assign": livreurAssign,
|
||||
"delivery_address": command["adresse"],
|
||||
})
|
||||
}
|
||||
@@ -1,794 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GeocodeAddress(c *gin.Context) {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse requise"})
|
||||
return
|
||||
}
|
||||
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
// Tentative de correction — resolveAddress ne touche pas à c.JSON
|
||||
suggestion, err := resolveAddress(geoService, req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ Adresse corrigée: '%s' → '%s' (confiance %.0f%%)",
|
||||
req.Address, suggestion.CorrectedAddress, suggestion.Confidence*100)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"latitude": suggestion.Coordinates.Latitude,
|
||||
"longitude": suggestion.Coordinates.Longitude,
|
||||
"display_name": suggestion.CorrectedAddress,
|
||||
"correction_applied": suggestion.CorrectionApplied,
|
||||
"confidence": suggestion.Confidence,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", req.Address, location.Latitude, location.Longitude)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
"correction_applied": false,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveAddress : logique pure, sans toucher à gin.Context
|
||||
func resolveAddress(geoService *services.GeoService, address string) (*services.AddressSuggestion, error) {
|
||||
return geoService.CorrectionService().ResolveAddress(address)
|
||||
}
|
||||
|
||||
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
|
||||
func FindNearestDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var targetCoords services.Coordinates
|
||||
|
||||
// Si adresse fournie, la géocoder
|
||||
if req.Address != "" {
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de géocoder l'adresse",
|
||||
})
|
||||
return
|
||||
}
|
||||
targetCoords.Latitude = location.Latitude
|
||||
targetCoords.Longitude = location.Longitude
|
||||
} else if req.Latitude != 0 && req.Longitude != 0 {
|
||||
// Sinon utiliser les coordonnées fournies
|
||||
targetCoords.Latitude = req.Latitude
|
||||
targetCoords.Longitude = req.Longitude
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Fournir soit une adresse, soit des coordonnées GPS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Valider les coordonnées
|
||||
if err := services.ValidateCoordinates(targetCoords); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les livreurs disponibles
|
||||
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil || len(availableLivreurs) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur disponible",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Extraire les usernames
|
||||
usernames := make([]string, len(availableLivreurs))
|
||||
for i, livreur := range availableLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le plus proche (calcul rapide avec Haversine)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur avec position GPS valide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Recalculer l'ETA du plus proche avec TomTom pour plus de précision
|
||||
etaWithTraffic, distanceReal, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
||||
if err == nil {
|
||||
nearest.EstimatedTime = etaWithTraffic
|
||||
nearest.Distance = distanceReal
|
||||
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min avec trafic)",
|
||||
nearest.Username, nearest.Distance, nearest.EstimatedTime)
|
||||
} else {
|
||||
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min sans trafic)",
|
||||
nearest.Username, nearest.Distance, nearest.EstimatedTime)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"target": gin.H{
|
||||
"latitude": targetCoords.Latitude,
|
||||
"longitude": targetCoords.Longitude,
|
||||
},
|
||||
"nearest_delivery_person": gin.H{
|
||||
"username": nearest.Username,
|
||||
"latitude": nearest.Location.Latitude,
|
||||
"longitude": nearest.Location.Longitude,
|
||||
"distance_km": nearest.Distance,
|
||||
"eta_minutes": nearest.EstimatedTime,
|
||||
"traffic_aware": err == nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
|
||||
func GetAllDeliveryDistances(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var targetCoords services.Coordinates
|
||||
|
||||
if req.Address != "" {
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de géocoder l'adresse",
|
||||
})
|
||||
return
|
||||
}
|
||||
targetCoords.Latitude = location.Latitude
|
||||
targetCoords.Longitude = location.Longitude
|
||||
} else if req.Latitude != 0 && req.Longitude != 0 {
|
||||
targetCoords.Latitude = req.Latitude
|
||||
targetCoords.Longitude = req.Longitude
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Fournir soit une adresse, soit des coordonnées GPS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.ValidateCoordinates(targetCoords); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil || len(availableLivreurs) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur disponible",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernames := make([]string, len(availableLivreurs))
|
||||
for i, livreur := range availableLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
distances, err := geoService.GetAllDeliveryDistances(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur calcul des distances",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"target": gin.H{
|
||||
"latitude": targetCoords.Latitude,
|
||||
"longitude": targetCoords.Longitude,
|
||||
},
|
||||
"delivery_persons": distances,
|
||||
"count": len(distances),
|
||||
})
|
||||
}
|
||||
|
||||
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
status, ok := command["status"].(string)
|
||||
if !ok || (status != "pending" && status != "priority") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "La commande doit être en statut 'pending' ou 'priority'",
|
||||
"current_status": status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'adresse de livraison de la commande
|
||||
address, ok := command["adresse"].(string)
|
||||
if !ok || address == "" || address == "Adresse non spécifiée" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cette commande n'a pas d'adresse de livraison valide",
|
||||
"command_id": commandID,
|
||||
"message": "L'adresse de livraison doit être définie lors de la création de la commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse de livraison: %s", address)
|
||||
|
||||
// Géocoder l'adresse
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de géocoder l'adresse de livraison",
|
||||
"address": address,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", address, location.Latitude, location.Longitude)
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": location.Latitude,
|
||||
"lon": location.Longitude,
|
||||
})
|
||||
if err := db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, 4*time.Hour).Err(); err != nil {
|
||||
log.Printf("⚠️ Impossible de sauvegarder coordonnées destination: %v", err)
|
||||
} else {
|
||||
log.Printf("📍 Coordonnées destination sauvegardées pour commande %d: (%.6f, %.6f)",
|
||||
commandID, location.Latitude, location.Longitude)
|
||||
}
|
||||
|
||||
targetCoords := services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
}
|
||||
|
||||
// Compter le nombre de livreurs actifs
|
||||
activeCount, _ := database.CountActiveDeliverymen()
|
||||
|
||||
if activeCount == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur actif (tous sont offline)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
|
||||
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
if activeCount == 1 {
|
||||
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Impossible de trouver le livreur actif",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculer ETA avec TomTom
|
||||
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, singleDeliveryman, location.Latitude, location.Longitude)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur calcul ETA",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de l'assignation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(singleDeliveryman, "busy", commandID)
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(singleDeliveryman)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée au seul livreur actif (sans limite)",
|
||||
"command_id": commandID,
|
||||
"assigned_to": gin.H{
|
||||
"username": singleDeliveryman,
|
||||
"travel_time": travelTime,
|
||||
"distance_km": distance,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
"single_driver": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
},
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
|
||||
|
||||
if allAtCapacity && numActive > 1 {
|
||||
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
|
||||
|
||||
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Impossible de trouver un livreur pour la distribution forcée",
|
||||
})
|
||||
return
|
||||
}
|
||||
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur calcul ETA",
|
||||
})
|
||||
return
|
||||
}
|
||||
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de l'assignation forcée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(leastLoaded, "busy", commandID)
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(leastLoaded)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
|
||||
"command_id": commandID,
|
||||
"forced": true,
|
||||
"assigned_to": gin.H{
|
||||
"username": leastLoaded,
|
||||
"travel_time": travelTime,
|
||||
"distance_km": distance,
|
||||
"queue_position": currentSize + 1,
|
||||
"over_capacity": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
},
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur actif avec capacité disponible",
|
||||
"active_count": activeCount,
|
||||
"max_per_deliveryman": db.MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernames := make([]string, len(activeLivreurs))
|
||||
for i, livreur := range activeLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur avec position GPS valide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
||||
if err != nil {
|
||||
// Fallback sur le calcul initial
|
||||
travelTime = nearest.EstimatedTime
|
||||
distance = nearest.Distance
|
||||
log.Printf("⚠️ TomTom indisponible, utilisation du calcul Haversine")
|
||||
}
|
||||
|
||||
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
|
||||
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de l'assignation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(nearest.Username)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée à la queue du livreur",
|
||||
"command_id": commandID,
|
||||
"assigned_to": gin.H{
|
||||
"username": nearest.Username,
|
||||
"distance_km": distance,
|
||||
"travel_time": travelTime,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
"single_driver": activeCount == 1,
|
||||
"traffic_aware": err == nil,
|
||||
},
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
},
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
}
|
||||
|
||||
func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commands, err := database.GetAllCommands("pending", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération des commandes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(commands) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Aucune commande en attente",
|
||||
"assigned": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📋 %d commandes en attente à assigner", len(commands))
|
||||
|
||||
var assigned []gin.H
|
||||
var failed []gin.H
|
||||
|
||||
for _, cmd := range commands {
|
||||
commandID, ok := cmd["id"].(int)
|
||||
if !ok {
|
||||
if idFloat, ok := cmd["id"].(float64); ok {
|
||||
commandID = int(idFloat)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
address, ok := cmd["adresse"].(string)
|
||||
if !ok || address == "" || address == "Adresse non spécifiée" {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": "Adresse de livraison manquante",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": fmt.Sprintf("Impossible de géocoder: %s", address),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
targetCoords := services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
}
|
||||
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": "Aucun livreur disponible",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
usernames := make([]string, len(activeLivreurs))
|
||||
for i, livreur := range activeLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": "Aucun livreur avec position GPS",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
travelTime := nearest.EstimatedTime
|
||||
distance := nearest.Distance
|
||||
|
||||
// Assigner à la queue
|
||||
err = database.AssignCommandToDeliverymanQueue(commandID, nearest.Username, travelTime)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
var totalETA, waitTime any
|
||||
totalETA = "N/A"
|
||||
waitTime = "N/A"
|
||||
|
||||
if etaData != nil {
|
||||
if val, exists := etaData["total_eta_minutes"]; exists {
|
||||
totalETA = val
|
||||
}
|
||||
if val, exists := etaData["wait_time_minutes"]; exists {
|
||||
waitTime = val
|
||||
}
|
||||
}
|
||||
|
||||
assigned = append(assigned, gin.H{
|
||||
"command_id": commandID,
|
||||
"assigned_to": nearest.Username,
|
||||
"distance_km": distance,
|
||||
"total_eta_minutes": totalETA,
|
||||
"wait_time_minutes": waitTime,
|
||||
"travel_time": travelTime,
|
||||
})
|
||||
|
||||
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
|
||||
}
|
||||
|
||||
queuesOverview, _ := database.GetAllQueuesOverview()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("%d commandes assignées, %d échecs", len(assigned), len(failed)),
|
||||
"total_pending": len(commands),
|
||||
"assigned_count": len(assigned),
|
||||
"failed_count": len(failed),
|
||||
"assigned": assigned,
|
||||
"failed": failed,
|
||||
"queues_overview": queuesOverview,
|
||||
"note": "ETAs calculés avec Haversine pour rapidité, précision TomTom disponible individuellement",
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
|
||||
func GetAllDeliveryQueues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
overview, err := database.GetAllQueuesOverview()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération des queues",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var deliverymenDetails []gin.H
|
||||
|
||||
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
|
||||
for _, key := range keys {
|
||||
username := key[len("delivery:status:"):]
|
||||
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(username)
|
||||
|
||||
// Récupérer le statut
|
||||
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
|
||||
var status map[string]any
|
||||
if statusData != "" {
|
||||
json.Unmarshal([]byte(statusData), &status)
|
||||
}
|
||||
|
||||
deliverymenDetails = append(deliverymenDetails, gin.H{
|
||||
"username": username,
|
||||
"status": status["status"],
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"overview": overview,
|
||||
"deliverymen_detail": deliverymenDetails,
|
||||
})
|
||||
}
|
||||
|
||||
func GetDeliverymanQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
queueInfo, err := database.GetDeliverymanQueueInfo(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération de la queue",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
}
|
||||
|
||||
func ValidateAddress(c *gin.Context) {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse requise"})
|
||||
return
|
||||
}
|
||||
|
||||
if !geoService.IsValidAddress(req.Address) {
|
||||
c.JSON(http.StatusOK, gin.H{"valid": false, "message": "Adresse introuvable ou invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
location, _ := geoService.GeocodeAddress(req.Address)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"valid": true,
|
||||
"message": "Adresse valide",
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
func calculateTravelTimeWithTomTom(geoService *services.GeoService, deliverymanUsername string, targetLat, targetLon float64) (int, float64, error) {
|
||||
deliverymanLoc, err := geoService.GetDeliveryPersonLocation(deliverymanUsername)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("position du livreur introuvable: %w", err)
|
||||
}
|
||||
|
||||
targetCoords := services.Coordinates{
|
||||
Latitude: targetLat,
|
||||
Longitude: targetLon,
|
||||
}
|
||||
|
||||
travelTime, distance, err := services.GetETAWithTraffic(*deliverymanLoc, targetCoords)
|
||||
if err != nil {
|
||||
distance = services.CalculateDistance(*deliverymanLoc, targetCoords)
|
||||
travelTime = services.CalculateETA(distance)
|
||||
log.Printf("⚠️ TomTom indisponible pour %s, fallback: %.2f km -> %d min",
|
||||
deliverymanUsername, distance, travelTime)
|
||||
} else {
|
||||
log.Printf("🛣️ TomTom utilisé pour %s: %.2f km -> %d min (trafic réel)",
|
||||
deliverymanUsername, distance, travelTime)
|
||||
}
|
||||
|
||||
return travelTime, distance, nil
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
// ============================================
|
||||
// handlers/gps_handlers.go
|
||||
// Gestion des liens GPS et visualisation
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /api/v2/admin/protected/delivery-persons/:username/map-links
|
||||
func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
|
||||
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Position GPS non disponible pour ce livreur",
|
||||
"message": "Le livreur n'a pas encore partagé sa position ou est hors ligne",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if lat == 0 && lon == 0 {
|
||||
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Coordonnées GPS invalides (0,0)",
|
||||
"message": "Le livreur doit mettre à jour sa position",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mapLinks := database.GenerateMapLinks(lat, lon, username)
|
||||
|
||||
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"deliveryman": gin.H{
|
||||
"username": username,
|
||||
},
|
||||
"location": gin.H{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"valid": true,
|
||||
},
|
||||
"map_links": mapLinks,
|
||||
})
|
||||
}
|
||||
|
||||
func GetLivreurNavLink(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.GetString("username")
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous est pas assignée"})
|
||||
return
|
||||
}
|
||||
|
||||
var wazeLink string
|
||||
destLat, hasLat := command["dest_latitude"].(float64)
|
||||
destLon, hasLon := command["dest_longitude"].(float64)
|
||||
if hasLat && hasLon && destLat != 0 && destLon != 0 {
|
||||
wazeLink = fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", destLat, destLon)
|
||||
log.Printf("🗺️ [NAV_LINK] Lien coords pour cmd %d: %s", commandID, wazeLink)
|
||||
} else if adresse, _ := command["adresse"].(string); adresse != "" {
|
||||
wazeLink = fmt.Sprintf("waze://?q=%s&navigate=yes", url.QueryEscape(adresse))
|
||||
log.Printf("🗺️ [NAV_LINK] Lien adresse pour cmd %d: %s", commandID, wazeLink)
|
||||
} else {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Aucune destination disponible pour cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"waze_app": wazeLink,
|
||||
})
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
|
||||
// GET /api/v1/my-commands/history/detailed
|
||||
func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authentification requise",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
|
||||
|
||||
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var enrichedCommands []map[string]any
|
||||
|
||||
for _, command := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
|
||||
if commandID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
// Ajouter les items à la commande
|
||||
enrichedCommand := make(map[string]any)
|
||||
maps.Copy(enrichedCommand, command)
|
||||
enrichedCommand["items"] = items
|
||||
enrichedCommand["items_count"] = len(items)
|
||||
|
||||
enrichedCommands = append(enrichedCommands, enrichedCommand)
|
||||
}
|
||||
|
||||
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
|
||||
|
||||
client, err := database.GetClientByUsername(usernameStr)
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"commands": enrichedCommands,
|
||||
"count": len(enrichedCommands),
|
||||
}
|
||||
|
||||
if err == nil && client != nil {
|
||||
response["client_stats"] = gin.H{
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"total_commands": client.Command,
|
||||
"points_extra": client.PointsExtra,
|
||||
"penalties": client.Amende,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func GetOrderHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authentification requise",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
var commandID int
|
||||
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "ID de commande invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Commande non trouvée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cmdUsername, ok := command["username"].(string)
|
||||
if !ok || cmdUsername != usernameStr {
|
||||
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous appartient pas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
|
||||
logs = []map[string]any{}
|
||||
}
|
||||
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command": command,
|
||||
"logs": logs,
|
||||
"logs_count": len(logs),
|
||||
"items": items,
|
||||
"items_count": len(items),
|
||||
})
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type loginHistoryWeek struct {
|
||||
Week int `json:"week"`
|
||||
Entries []db.LoginHistoryEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// GetLivreurLoginHistory retourne l'historique de connexion d'un livreur pour un mois donné,
|
||||
// regroupé par semaine ISO (détail complet, pas d'agrégation par compteur).
|
||||
func GetLivreurLoginHistory(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
year := now.Year()
|
||||
month := int(now.Month())
|
||||
|
||||
if y := c.Query("year"); y != "" {
|
||||
parsed, err := strconv.Atoi(y)
|
||||
if err != nil || parsed < 2000 || parsed > 2100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Année invalide"})
|
||||
return
|
||||
}
|
||||
year = parsed
|
||||
}
|
||||
if m := c.Query("month"); m != "" {
|
||||
parsed, err := strconv.Atoi(m)
|
||||
if err != nil || parsed < 1 || parsed > 12 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Mois invalide"})
|
||||
return
|
||||
}
|
||||
month = parsed
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
entries, err := database.GetLivreurLoginHistoryByMonth(username, year, month)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération historique de connexion", err)
|
||||
return
|
||||
}
|
||||
|
||||
weekOrder := make([]int, 0)
|
||||
weekMap := make(map[int]*loginHistoryWeek)
|
||||
for _, e := range entries {
|
||||
_, isoWeek := e.CreatedAt.ISOWeek()
|
||||
w, ok := weekMap[isoWeek]
|
||||
if !ok {
|
||||
w = &loginHistoryWeek{Week: isoWeek}
|
||||
weekMap[isoWeek] = w
|
||||
weekOrder = append(weekOrder, isoWeek)
|
||||
}
|
||||
w.Entries = append(w.Entries, e)
|
||||
}
|
||||
|
||||
weeks := make([]*loginHistoryWeek, 0, len(weekOrder))
|
||||
for _, wk := range weekOrder {
|
||||
weeks = append(weeks, weekMap[wk])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": username,
|
||||
"year": year,
|
||||
"month": month,
|
||||
"weeks": weeks,
|
||||
"count": len(entries),
|
||||
})
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetClientNotifications retourne les notifications du client connecté
|
||||
// GET /api/v1/notifications
|
||||
func GetClientNotifications(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
CommandID int `json:"command_id"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
|
||||
notifications := make([]Notification, 0, len(results))
|
||||
unreadCount := 0
|
||||
|
||||
for _, raw := range results {
|
||||
var n Notification
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
notifications = append(notifications, n)
|
||||
if !n.Read {
|
||||
unreadCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": notifications,
|
||||
"unread_count": unreadCount,
|
||||
"total": len(notifications),
|
||||
})
|
||||
}
|
||||
|
||||
// GetLivreurNotifications retourne les notifications du livreur connecté
|
||||
// GET /api/v1/livreur/notifications
|
||||
func GetLivreurNotifications(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
CommandID int `json:"command_id"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
|
||||
notifications := make([]Notification, 0, len(results))
|
||||
unreadCount := 0
|
||||
|
||||
for _, raw := range results {
|
||||
var n Notification
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
notifications = append(notifications, n)
|
||||
if !n.Read {
|
||||
unreadCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [LIVREUR_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": notifications,
|
||||
"unread_count": unreadCount,
|
||||
"total": len(notifications),
|
||||
})
|
||||
}
|
||||
|
||||
// MarkLivreurNotificationsRead marque toutes les notifications du livreur comme lues
|
||||
// POST /api/v1/livreur/notifications/read
|
||||
func MarkLivreurNotificationsRead(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR_MARK_READ] Erreur Redis LRange: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
if read, ok := n["read"].(bool); ok && read {
|
||||
continue
|
||||
}
|
||||
n["read"] = true
|
||||
updated, _ := json.Marshal(n)
|
||||
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
|
||||
markedCount++
|
||||
}
|
||||
|
||||
log.Printf("✅ [LIVREUR_MARK_READ] %d notifications marquées lues pour %s", markedCount, username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"marked_count": markedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkNotificationsRead marque toutes les notifications comme lues
|
||||
// POST /api/v1/notifications/read
|
||||
func MarkNotificationsRead(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
// Récupérer toutes les notifications
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [MARK_NOTIFICATIONS_READ] Erreur Redis LRange: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
// Réécrire chaque notification avec read=true
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
if read, ok := n["read"].(bool); ok && read {
|
||||
continue
|
||||
}
|
||||
n["read"] = true
|
||||
updated, _ := json.Marshal(n)
|
||||
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
|
||||
markedCount++
|
||||
}
|
||||
|
||||
log.Printf("✅ [MARK_NOTIFICATIONS_READ] %d notifications marquées lues pour %s", markedCount, username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"marked_count": markedCount,
|
||||
})
|
||||
}
|
||||
@@ -1,596 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type BasketsRequest struct {
|
||||
Username string `json:"username"`
|
||||
ProductID int `json:"product_id"`
|
||||
NameProduct string `json:"name_product"`
|
||||
Category string `json:"category"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// POST /api/v1/panier/add
|
||||
func AddProductsBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req BasketsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
username, ok := c.Get("username")
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
req.Username = username.(string)
|
||||
|
||||
if req.Quantity <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
|
||||
return
|
||||
}
|
||||
if req.ProductID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "product_id requis"})
|
||||
return
|
||||
}
|
||||
|
||||
if p, err := database.GetProductByID(req.ProductID); err == nil && p.ComingSoon {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Ce produit n'est pas encore disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
panier, err := database.AddToBasket(req.Username, req.ProductID, req.Quantity)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADD_PANIER] product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
|
||||
if err.Error() == "stock insuffisant" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant"})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "prix introuvable") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun prix configuré pour ce produit"})
|
||||
return
|
||||
}
|
||||
utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Produit ajouté au panier avec succès",
|
||||
"panier": panier,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllBaskets(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
|
||||
log.Printf("📦 [GET_PANIER] Requête pour: %s", username)
|
||||
|
||||
if username == "" {
|
||||
log.Printf("❌ [GET_PANIER] Username manquant")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le paramètre 'username' est requis"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
if username != authUsernameStr {
|
||||
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
|
||||
log.Printf(" Username du JWT: %s", authUsernameStr)
|
||||
log.Printf(" Username demandé: %s", username)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Vous ne pouvez accéder qu'à votre panier",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
username = authUsernameStr
|
||||
|
||||
_, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur inexistant"})
|
||||
return
|
||||
}
|
||||
|
||||
baskets, err := database.GetAllProductsInBasket(username)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la récupération du panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
var totalAmount float64
|
||||
for _, item := range baskets {
|
||||
totalAmount += item.Price
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Panier récupéré avec succès",
|
||||
"panier": baskets,
|
||||
"count": len(baskets),
|
||||
"total_amount": totalAmount,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteProductFromBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
ID int `json:"id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
|
||||
|
||||
itemUsername, err := database.GetBasketItemOwner(req.ID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [DEL_PANIER] Article non trouvé: id=%d", req.ID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Article non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if itemUsername != authUsernameStr {
|
||||
log.Printf("❌ [DEL_PANIER] ⚠️ TENTATIVE DE SUPPRESSION NON AUTORISÉE!")
|
||||
log.Printf(" Client JWT: %s", authUsernameStr)
|
||||
log.Printf(" Propriétaire article: %s", itemUsername)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Vous ne pouvez supprimer que vos articles",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.DeleteProductFromBasket(req.ID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la suppression", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DEL_PANIER] Article %d supprimé", req.ID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Produit supprimé du panier avec succès",
|
||||
"item_id": req.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func ClearBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
log.Printf("🧹 [CLEAR_PANIER] Vider panier de: %s", authUsernameStr)
|
||||
|
||||
baskets, err := database.GetAllProductsInBasket(authUsernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLEAR_PANIER] Erreur récupération: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors du vidage du panier",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.ClearBasket(authUsernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors du vidage du panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CLEAR_PANIER] Panier %s vidé: %d articles supprimés", authUsernameStr, len(baskets))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Panier vidé avec succès",
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/checkout
|
||||
func ValidateBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
|
||||
return
|
||||
}
|
||||
usernameStr := username.(string)
|
||||
|
||||
lockKey := fmt.Sprintf("checkout_lock:%s", usernameStr)
|
||||
locked, errLock := db.Redis.SetNX(db.RedisCtx, lockKey, "1", 30*time.Second).Result()
|
||||
if errLock != nil || !locked {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Un checkout est déjà en cours pour ce compte"})
|
||||
return
|
||||
}
|
||||
defer db.Redis.Del(db.RedisCtx, lockKey)
|
||||
|
||||
var req struct {
|
||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||
UseReferralBalance bool `json:"use_referral_balance"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
PayCurrency string `json:"pay_currency"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
||||
return
|
||||
}
|
||||
|
||||
cmd := &models.Command{DeliveryAddress: req.DeliveryAddress}
|
||||
if err := database.CheckAddress(cmd); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse non reconnue", "corrected_address": cmd.DeliveryAddress})
|
||||
return
|
||||
}
|
||||
req.DeliveryAddress = cmd.DeliveryAddress
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
|
||||
|
||||
items, err := database.GetBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer le panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
log.Printf("❌ [CHECKOUT] Panier vide")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Panier vide"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
||||
|
||||
var cartTotal float64
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok {
|
||||
cartTotal += price
|
||||
}
|
||||
}
|
||||
|
||||
hasRewardItem := false
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok && price == 0 {
|
||||
hasRewardItem = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasRewardItem && cartTotal <= 0 {
|
||||
log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"})
|
||||
return
|
||||
}
|
||||
appSettings, _ := database.GetSettings()
|
||||
|
||||
var referralBalance float64
|
||||
if req.UseReferralBalance && appSettings.ReferralEnabled {
|
||||
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
|
||||
}
|
||||
|
||||
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal, appSettings.PostalZones)
|
||||
if !zoneResult.OK {
|
||||
if zoneResult.ZoneName == "inconnue" {
|
||||
log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Adresse invalide : aucun code postal détecté",
|
||||
})
|
||||
} else if zoneResult.ZoneName == "hors zone" {
|
||||
log.Printf("❌ [CHECKOUT] Code postal %s hors zone de livraison", zoneResult.PostalCode)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Livraison non disponible pour ce code postal",
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
})
|
||||
} else {
|
||||
log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
|
||||
"zone": zoneResult.ZoneName,
|
||||
"minimum": zoneResult.MinAmount,
|
||||
"cart_total": cartTotal,
|
||||
"missing": zoneResult.MinAmount - cartTotal,
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
"referral_balance": referralBalance,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var referralUsed float64
|
||||
if req.UseReferralBalance && referralBalance > 0 {
|
||||
effectivePayment := cartTotal - referralBalance
|
||||
if effectivePayment < zoneResult.MinAmount {
|
||||
needed := zoneResult.MinAmount + referralBalance
|
||||
log.Printf("❌ [CHECKOUT] Crédit parrainage %.2f€ mais panier insuffisant: %.2f€ < %.2f€ requis", referralBalance, cartTotal, needed)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Avec %.2f€ de crédit parrainage, votre commande doit atteindre %.2f€ (minimum zone %.0f€ + crédit utilisé)", referralBalance, needed, zoneResult.MinAmount),
|
||||
"zone": zoneResult.ZoneName,
|
||||
"minimum": needed,
|
||||
"cart_total": cartTotal,
|
||||
"missing": needed - cartTotal,
|
||||
"referral_balance": referralBalance,
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
referralUsed = referralBalance
|
||||
}
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed)
|
||||
|
||||
if referralUsed > 0 {
|
||||
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
|
||||
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Solde parrainage insuffisant ou déjà utilisé"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
|
||||
}
|
||||
|
||||
unavailable, err := database.GetUnavailableBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification produits", err)
|
||||
return
|
||||
}
|
||||
if len(unavailable) > 0 {
|
||||
log.Printf("❌ [CHECKOUT] Produits sans prix actif: %v", unavailable)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Certains produits de votre panier ne sont plus disponibles",
|
||||
"products": unavailable,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isCrypto := req.PaymentMethod == "crypto"
|
||||
if isCrypto {
|
||||
npRaw, npExists := c.Get("nowpayments")
|
||||
np, npOk := npRaw.(*services.NowPaymentsClient)
|
||||
if !npExists || !npOk || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
|
||||
return
|
||||
}
|
||||
if req.PayCurrency == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pay_currency requis pour le paiement crypto (ex: btc, eth, ltc)"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
command, err := database.CreateCommandWithAddress(usernameStr, req.DeliveryAddress)
|
||||
if err != nil {
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
|
||||
if strings.Contains(err.Error(), "stock insuffisant") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Le stock ou le produit n'est plus disponible, repasse commande"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"})
|
||||
return
|
||||
}
|
||||
commandID := command.ID
|
||||
|
||||
if referralUsed > 0 {
|
||||
if err := database.SetCommandReferralUsed(commandID, referralUsed); err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Impossible de sauvegarder referral_used sur commande: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
|
||||
|
||||
if isCrypto {
|
||||
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
||||
payReq := &services.CreatePaymentRequest{
|
||||
PriceAmount: cartTotal,
|
||||
PriceCurrency: "eur",
|
||||
PayCurrency: req.PayCurrency,
|
||||
OrderID: fmt.Sprintf("%d", commandID),
|
||||
IPNCallbackURL: ipnURL,
|
||||
}
|
||||
payResp, err := np.CreatePayment(payReq)
|
||||
if err != nil {
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
log.Printf("❌ [CHECKOUT] Erreur création paiement NowPayments: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Impossible d'initier le paiement crypto"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
|
||||
}
|
||||
|
||||
priceAmt, _ := payResp.PriceAmount.Float64()
|
||||
payAmt, _ := payResp.PayAmount.Float64()
|
||||
if _, err := database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt); err != nil {
|
||||
log.Printf("❌ [CHECKOUT] Erreur enregistrement paiement crypto (commande %d, nowpayment %s): %v", commandID, payResp.PaymentID.String(), err)
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne lors de l'enregistrement du paiement"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"payment_method": "crypto",
|
||||
"payment_status": payResp.Status,
|
||||
"pay_address": payResp.PayAddress,
|
||||
"pay_amount": payAmt,
|
||||
"pay_currency": payResp.PayCurrency,
|
||||
"price_amount": priceAmt,
|
||||
"price_currency": payResp.PriceCurrency,
|
||||
"message": "Commande créée - En attente de paiement crypto",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
|
||||
|
||||
var assigned bool
|
||||
var assignInfo gin.H
|
||||
|
||||
location, err := geoService.GeocodeAddress(req.DeliveryAddress)
|
||||
if err == nil {
|
||||
log.Printf("📍 [CHECKOUT] Adresse géocodée: %.6f,%.6f", location.Latitude, location.Longitude)
|
||||
|
||||
usernames, errEligible := database.GetEligibleDeliverymenForCommand(commandID)
|
||||
if errEligible == nil && len(usernames) > 0 {
|
||||
log.Printf("🚚 [CHECKOUT] %d livreur(s) éligible(s) disponibles", len(usernames))
|
||||
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
}, usernames)
|
||||
|
||||
if err == nil {
|
||||
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
|
||||
|
||||
travelTime, distance, err := services.CalculateETAWithTomTom(
|
||||
nearest.Location,
|
||||
services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
// Fallback sur ETA simple
|
||||
travelTime = nearest.EstimatedTime
|
||||
distance = nearest.Distance
|
||||
log.Printf("⚠️ [CHECKOUT] Fallback ETA: %d min", travelTime)
|
||||
}
|
||||
|
||||
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
|
||||
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(
|
||||
commandID,
|
||||
nearest.Username,
|
||||
travelTime,
|
||||
location.Latitude,
|
||||
location.Longitude,
|
||||
req.DeliveryAddress,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
|
||||
} else {
|
||||
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
|
||||
}
|
||||
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
|
||||
if referralUsed > 0 {
|
||||
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
|
||||
}
|
||||
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
|
||||
}
|
||||
clientOrderID := database.GetClientOrderID(commandID)
|
||||
clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID)
|
||||
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
|
||||
|
||||
assigned = true
|
||||
assignInfo = gin.H{
|
||||
"username": nearest.Username,
|
||||
"distance_km": distance,
|
||||
"travel_time": travelTime,
|
||||
}
|
||||
log.Printf("✅ [CHECKOUT] Commande assignée à %s", nearest.Username)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [CHECKOUT] Aucun livreur trouvé: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [CHECKOUT] Aucun livreur éligible disponible")
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
|
||||
}
|
||||
|
||||
newBalance, _ := database.GetClientReferralBalance(usernameStr)
|
||||
resp := gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"client_order_number": command.ClientOrderID,
|
||||
"delivery_address": req.DeliveryAddress,
|
||||
"status": "pending",
|
||||
"referral_used": referralUsed,
|
||||
"referral_balance": newBalance,
|
||||
}
|
||||
|
||||
if assigned {
|
||||
resp["message"] = "Commande créée et livreur assigné automatiquement"
|
||||
resp["auto_assigned"] = true
|
||||
resp["assigned_to"] = assignInfo
|
||||
resp["status"] = "assigned"
|
||||
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d assignée", commandID)
|
||||
} else {
|
||||
resp["message"] = "Commande créée - En attente d'assignation"
|
||||
resp["auto_assigned"] = false
|
||||
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d en attente", commandID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func getBaseURL(c *gin.Context) string {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
scheme = "http"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetClientParrainAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
var req struct {
|
||||
Parrain string `json:"parrain" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Champ parrain requis"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Parrain == targetUsername {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Un client ne peut pas être son propre parrain"})
|
||||
return
|
||||
}
|
||||
|
||||
parrain, err := database.GetClientByUsername(req.Parrain)
|
||||
if err != nil || parrain == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := database.GetClientParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification parrain", err)
|
||||
return
|
||||
}
|
||||
if existing != "" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce client a déjà un parrain", "parrain": existing})
|
||||
return
|
||||
}
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
creditAmount := 0.0
|
||||
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
|
||||
creditAmount = settings.ReferralAmount
|
||||
}
|
||||
|
||||
if err := database.SetClientParrainAndCredit(targetUsername, req.Parrain, creditAmount); err != nil {
|
||||
utils.ServerErr(c, "Erreur enregistrement parrain", err)
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Parrain enregistré",
|
||||
"client": targetUsername,
|
||||
"parrain": req.Parrain,
|
||||
"amount_credited": creditAmount,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyParrainInfo — GET /api/v1/parrain (client authentifié)
|
||||
// Retourne le parrain du client + ses filleuls + son solde parrainage.
|
||||
func GetMyParrainInfo(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
|
||||
return
|
||||
}
|
||||
clientUsername := username.(string)
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
|
||||
parrain, err := database.GetClientParrain(clientUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération parrain", err)
|
||||
return
|
||||
}
|
||||
|
||||
filleuls, err := database.GetClientsByParrain(clientUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération filleuls", err)
|
||||
return
|
||||
}
|
||||
|
||||
balance, err := database.GetClientReferralBalance(clientUsername)
|
||||
if err != nil {
|
||||
balance = 0
|
||||
}
|
||||
|
||||
filleulNames := make([]string, 0, len(filleuls))
|
||||
for _, f := range filleuls {
|
||||
filleulNames = append(filleulNames, f.Username)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"parrain": parrain,
|
||||
"filleuls": filleulNames,
|
||||
"filleul_count": len(filleulNames),
|
||||
"referral_balance": balance,
|
||||
"referral_enabled": settings.ReferralEnabled,
|
||||
"referral_amount": settings.ReferralAmount,
|
||||
})
|
||||
}
|
||||
|
||||
// GetClientParrainAdmin — GET /api/v2/admin/protected/client/:username/parrain (admin)
|
||||
// Retourne les infos parrainage d'un client spécifique.
|
||||
func GetClientParrainAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
parrain, err := database.GetClientParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération parrain", err)
|
||||
return
|
||||
}
|
||||
|
||||
filleuls, err := database.GetClientsByParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération filleuls", err)
|
||||
return
|
||||
}
|
||||
|
||||
balance, _ := database.GetClientReferralBalance(targetUsername)
|
||||
|
||||
filleulNames := make([]string, 0, len(filleuls))
|
||||
for _, f := range filleuls {
|
||||
filleulNames = append(filleulNames, f.Username)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": targetUsername,
|
||||
"parrain": parrain,
|
||||
"filleuls": filleulNames,
|
||||
"filleul_count": len(filleulNames),
|
||||
"referral_balance": balance,
|
||||
})
|
||||
}
|
||||
|
||||
// GetParrainStatsAdmin — GET /api/v2/admin/protected/parrain/stats (admin)
|
||||
// Retourne la liste de tous les parrains avec leur nombre de filleuls et leur solde.
|
||||
func GetParrainStatsAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
stats, err := database.GetParrainStats()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération stats parrainage", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"parrains": stats,
|
||||
"total": len(stats),
|
||||
})
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// normalizeRewardCategoryType retombe sur "free_product" pour toute valeur
|
||||
// vide ou inconnue — rétrocompatibilité avec les configurations enregistrées
|
||||
// avant l'introduction du type par catégorie (RewardCategoryConfig.Type).
|
||||
func normalizeRewardCategoryType(t string) string {
|
||||
if t == "half_price_product" {
|
||||
return "half_price_product"
|
||||
}
|
||||
return "free_product"
|
||||
}
|
||||
|
||||
// eligibleRewardProducts détermine, pour un pool donné, quels product_id de
|
||||
// reward.RewardItems sont éligibles et avec quel type de récompense
|
||||
// ("free_product" | "half_price_product") : sa catégorie (via CategoryConfigs)
|
||||
// doit faire partie des catégories du pool, soit par whitelist explicite
|
||||
// (ProductIDs) soit par correspondance de catégorie produit (AllProducts).
|
||||
func eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string {
|
||||
eligible := make(map[int]string)
|
||||
if reward == nil {
|
||||
return eligible
|
||||
}
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
if !poolCategories[cfg.Category] {
|
||||
continue
|
||||
}
|
||||
rewardType := normalizeRewardCategoryType(cfg.Type)
|
||||
if cfg.AllProducts {
|
||||
for pid, cat := range productCategories {
|
||||
if cat == cfg.Category {
|
||||
eligible[pid] = rewardType
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, pid := range cfg.ProductIDs {
|
||||
eligible[pid] = rewardType
|
||||
}
|
||||
}
|
||||
}
|
||||
return eligible
|
||||
}
|
||||
|
||||
// categoryConfigTypeForProduct détermine le type de récompense applicable à un
|
||||
// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé
|
||||
// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis.
|
||||
func categoryConfigTypeForProduct(reward *models.PointsReward, productID int, productCategory string) string {
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
matches := false
|
||||
if cfg.AllProducts {
|
||||
matches = cfg.Category == productCategory
|
||||
} else {
|
||||
for _, pid := range cfg.ProductIDs {
|
||||
if pid == productID {
|
||||
matches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
return normalizeRewardCategoryType(cfg.Type)
|
||||
}
|
||||
}
|
||||
return "free_product"
|
||||
}
|
||||
|
||||
// effectiveRewardPrice calcule le prix réellement facturé pour un item
|
||||
// récompense selon le type de sa catégorie : 0€ pour "free_product", 50% du
|
||||
// prix catalogue actif (palier correspondant à la quantité) pour
|
||||
// "half_price_product". Erreur si le prix catalogue est introuvable (produit
|
||||
// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas
|
||||
// être proposée/réclamée plutôt que de facturer un montant incorrect.
|
||||
func effectiveRewardPrice(database *db.Database, item models.RewardItem, rewardType string) (float64, error) {
|
||||
if rewardType != "half_price_product" {
|
||||
return 0, nil
|
||||
}
|
||||
catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.ProductID, err)
|
||||
}
|
||||
return math.Round(catalogPrice/2*100) / 100, nil
|
||||
}
|
||||
|
||||
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
|
||||
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
|
||||
func GetMyPointsRewards(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture paramètres", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !settings.PointsEnabled || len(settings.PointsPools) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": false, "pools": []gin.H{}, "reward": nil})
|
||||
return
|
||||
}
|
||||
|
||||
pointsExtra, pointsRedeemed, err := database.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture points", err)
|
||||
return
|
||||
}
|
||||
|
||||
reward := settings.PointsReward
|
||||
|
||||
type EligibleConfigResponse struct {
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
AllProducts bool `json:"all_products"`
|
||||
ProductIDs []int `json:"product_ids"`
|
||||
ProductNames []string `json:"product_names"`
|
||||
}
|
||||
|
||||
type RewardItemResponse struct {
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type PoolInfo struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Points int `json:"points"`
|
||||
RewardsEarned int `json:"rewards_earned"`
|
||||
RewardsClaimed int `json:"rewards_claimed"`
|
||||
RewardsAvailable int `json:"rewards_available"`
|
||||
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
|
||||
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
|
||||
}
|
||||
|
||||
// Collecter tous les product_ids nécessaires en un seul passage
|
||||
allProductIDs := make([]int, 0)
|
||||
if reward != nil {
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
if !cfg.AllProducts {
|
||||
allProductIDs = append(allProductIDs, cfg.ProductIDs...)
|
||||
}
|
||||
}
|
||||
for _, item := range reward.RewardItems {
|
||||
if item.ProductID > 0 {
|
||||
allProductIDs = append(allProductIDs, item.ProductID)
|
||||
}
|
||||
}
|
||||
}
|
||||
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
|
||||
productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs)
|
||||
|
||||
pools := make([]PoolInfo, 0, len(settings.PointsPools))
|
||||
for _, pool := range settings.PointsPools {
|
||||
pts := pointsExtra[pool.Key]
|
||||
redeemed := pointsRedeemed[pool.Key]
|
||||
|
||||
var earned, available int
|
||||
if reward != nil && reward.Threshold > 0 {
|
||||
earned = pts / reward.Threshold
|
||||
available = earned - redeemed
|
||||
available = max(earned-redeemed, 0)
|
||||
}
|
||||
|
||||
poolCats := make(map[string]bool, len(pool.Categories))
|
||||
for _, c := range pool.Categories {
|
||||
poolCats[c] = true
|
||||
}
|
||||
eligibleConfigs := make([]EligibleConfigResponse, 0)
|
||||
if reward != nil {
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
if !poolCats[cfg.Category] {
|
||||
continue
|
||||
}
|
||||
names := make([]string, 0, len(cfg.ProductIDs))
|
||||
for _, pid := range cfg.ProductIDs {
|
||||
if n, ok := productNames[pid]; ok {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
||||
Category: cfg.Category,
|
||||
Type: normalizeRewardCategoryType(cfg.Type),
|
||||
AllProducts: cfg.AllProducts,
|
||||
ProductIDs: cfg.ProductIDs,
|
||||
ProductNames: names,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories)
|
||||
eligibleRewardItems := make([]RewardItemResponse, 0)
|
||||
if reward != nil {
|
||||
for _, item := range reward.RewardItems {
|
||||
rewardType, ok := eligibleProducts[item.ProductID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, item, rewardType)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
|
||||
continue
|
||||
}
|
||||
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
|
||||
ProductID: item.ProductID,
|
||||
ProductName: productNames[item.ProductID],
|
||||
Quantity: item.Quantity,
|
||||
Price: price,
|
||||
Type: rewardType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pools = append(pools, PoolInfo{
|
||||
Key: pool.Key,
|
||||
Name: pool.Name,
|
||||
Points: pts,
|
||||
RewardsEarned: earned,
|
||||
RewardsClaimed: redeemed,
|
||||
RewardsAvailable: available,
|
||||
EligibleConfigs: eligibleConfigs,
|
||||
EligibleRewardItems: eligibleRewardItems,
|
||||
})
|
||||
}
|
||||
|
||||
// Construire la liste des produits récompense avec leurs noms (aperçu
|
||||
// global, indépendant d'un pool précis — le type/prix effectif par pool
|
||||
// est celui exposé dans pools[].eligible_reward_items).
|
||||
var rewardMeta gin.H
|
||||
if reward != nil {
|
||||
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
|
||||
for _, item := range reward.RewardItems {
|
||||
if item.ProductID <= 0 {
|
||||
continue
|
||||
}
|
||||
rewardType := categoryConfigTypeForProduct(reward, item.ProductID, productCategories[item.ProductID])
|
||||
price, err := effectiveRewardPrice(database, item, rewardType)
|
||||
if err != nil {
|
||||
price = item.Price // fallback indicatif si le prix catalogue est momentanément indisponible
|
||||
}
|
||||
name := productNames[item.ProductID]
|
||||
rewardItems = append(rewardItems, RewardItemResponse{
|
||||
ProductID: item.ProductID,
|
||||
ProductName: name,
|
||||
Quantity: item.Quantity,
|
||||
Price: price,
|
||||
Type: rewardType,
|
||||
})
|
||||
}
|
||||
rewardMeta = gin.H{
|
||||
"threshold": reward.Threshold,
|
||||
"description": reward.Description,
|
||||
"reward_items": rewardItems,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": true, "pools": pools, "reward": rewardMeta})
|
||||
}
|
||||
|
||||
// ClaimMyReward réclame une récompense sur un pool donné si le client a atteint le seuil.
|
||||
func ClaimMyReward(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PoolKey string `json:"pool_key" binding:"required"`
|
||||
ProductID int `json:"product_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture paramètres", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !settings.PointsEnabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Système de points désactivé"})
|
||||
return
|
||||
}
|
||||
|
||||
reward := settings.PointsReward
|
||||
if reward == nil || reward.Threshold <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucune récompense configurée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le pool existe et récupérer ses catégories
|
||||
var selectedPool *models.PointsPool
|
||||
for i := range settings.PointsPools {
|
||||
if settings.PointsPools[i].Key == req.PoolKey {
|
||||
selectedPool = &settings.PointsPools[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if selectedPool == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
// Un produit récompense n'est éligible pour ce pool que si sa catégorie
|
||||
// fait partie des catégories du pool (via CategoryConfigs) — sans ce
|
||||
// filtre, un client pourrait réclamer n'importe quel produit récompense
|
||||
// (toutes catégories confondues) avec les points d'un pool quelconque.
|
||||
poolCategories := make(map[string]bool, len(selectedPool.Categories))
|
||||
for _, cat := range selectedPool.Categories {
|
||||
poolCategories[cat] = true
|
||||
}
|
||||
|
||||
rewardProductIDs := make([]int, 0, len(reward.RewardItems))
|
||||
for _, item := range reward.RewardItems {
|
||||
if item.ProductID > 0 {
|
||||
rewardProductIDs = append(rewardProductIDs, item.ProductID)
|
||||
}
|
||||
}
|
||||
productCategories, err := database.GetProductCategoriesByIDs(rewardProductIDs)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture catégories produits", err)
|
||||
return
|
||||
}
|
||||
|
||||
eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories)
|
||||
|
||||
// Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici,
|
||||
// avant toute écriture — si un item ne peut pas être tarifé (produit sans
|
||||
// palier de prix actif), la réclamation entière échoue proprement, avant
|
||||
// même de démarrer la transaction de consommation de points.
|
||||
eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems))
|
||||
for _, item := range reward.RewardItems {
|
||||
rewardType, ok := eligibleProducts[item.ProductID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, item, rewardType)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLAIM] %s: %v", username, err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||
return
|
||||
}
|
||||
item.Price = price
|
||||
eligibleItems = append(eligibleItems, item)
|
||||
}
|
||||
|
||||
itemsToAdd := eligibleItems
|
||||
if req.ProductID > 0 {
|
||||
itemsToAdd = nil
|
||||
for _, item := range eligibleItems {
|
||||
if item.ProductID == req.ProductID {
|
||||
itemsToAdd = []models.RewardItem{item}
|
||||
break
|
||||
}
|
||||
}
|
||||
if itemsToAdd == nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Ce produit n'est pas éligible pour cette récompense"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "pas de récompense disponible") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "produit récompense introuvable") {
|
||||
log.Printf("❌ [CLAIM] Configuration récompense invalide pour %s: %v", username, err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||
return
|
||||
}
|
||||
utils.ServerErr(c, "Erreur réclamation récompense", err)
|
||||
return
|
||||
}
|
||||
|
||||
productAdded := len(added) > 0
|
||||
var productNames []string
|
||||
for _, item := range added {
|
||||
productNames = append(productNames, item.ProductName)
|
||||
}
|
||||
if productAdded {
|
||||
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"description": reward.Description,
|
||||
"remaining_rewards": remaining,
|
||||
"product_added": productAdded,
|
||||
"product_names": productNames,
|
||||
})
|
||||
}
|
||||
|
||||
func AdminResetClientRedeemed(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
poolKey := c.Query("pool_key")
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.ResetClientRedeemed(username, poolKey); err != nil {
|
||||
utils.ServerErr(c, "Erreur reset récompenses", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,140 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SubmitLivreurRating(c *gin.Context) {
|
||||
clientUsername := c.GetString("username")
|
||||
if clientUsername == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
orderID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || orderID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Rating int `json:"rating" binding:"required,min=1,max=5"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Note invalide (1 à 5 requis)"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
ownerUsername, livreurUsername, err := database.GetOrderForRating(orderID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture commande", err)
|
||||
return
|
||||
}
|
||||
if ownerUsername == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable ou non terminée"})
|
||||
return
|
||||
}
|
||||
if ownerUsername != clientUsername {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non autorisée"})
|
||||
return
|
||||
}
|
||||
if livreurUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun livreur assigné à cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := database.GetOrderRating(orderID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification avis", err)
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Vous avez déjà noté ce livreur pour cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.SubmitLivreurRating(orderID, livreurUsername, clientUsername, req.Rating, req.Comment); err != nil {
|
||||
utils.ServerErr(c, "Erreur enregistrement avis", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func GetLivreurRatings(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
ratings, avg, err := database.GetLivreurRatings(username)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération avis", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ratings": ratings,
|
||||
"average": avg,
|
||||
"count": len(ratings),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyRatings retourne les avis reçus par le livreur connecté (uniquement les siens).
|
||||
func GetMyRatings(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" || c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
ratings, avg, err := database.GetLivreurRatings(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération avis"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ratings": ratings,
|
||||
"average": avg,
|
||||
"count": len(ratings),
|
||||
})
|
||||
}
|
||||
|
||||
func GetOrderRatingStatus(c *gin.Context) {
|
||||
clientUsername := c.GetString("username")
|
||||
if clientUsername == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
orderID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || orderID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
rating, err := database.GetOrderRating(orderID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur", err)
|
||||
return
|
||||
}
|
||||
|
||||
if rating == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"rated": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rated": true, "rating": rating.Rating, "comment": rating.Comment})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,96 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMyReferralBalance — GET /api/v1/referral/balance (client)
|
||||
func GetMyReferralBalance(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
if !settings.ReferralEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{"balance": 0, "referral_enabled": false})
|
||||
return
|
||||
}
|
||||
|
||||
balance, err := database.GetClientReferralBalance(username.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Solde de parrainage introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"balance": balance, "referral_enabled": true})
|
||||
}
|
||||
|
||||
// CreditClientReferralAdmin — POST /api/v2/admin/protected/client/:username/referral/credit (admin)
|
||||
func CreditClientReferralAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
var req struct {
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Amount <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Montant invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.CreditClientReferral(targetUsername, req.Amount); err != nil {
|
||||
utils.ServerErr(c, "Impossible de créditer le solde", err)
|
||||
return
|
||||
}
|
||||
|
||||
balance, _ := database.GetClientReferralBalance(targetUsername)
|
||||
log.Printf("✅ [REFERRAL] +%.2f€ crédité à %s, nouveau solde: %.2f€", req.Amount, targetUsername, balance)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Solde parrainage crédité",
|
||||
"balance": balance,
|
||||
})
|
||||
}
|
||||
|
||||
// ResetClientReferralAdmin — DELETE /api/v2/admin/protected/client/:username/referral/reset (admin)
|
||||
func ResetClientReferralAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
if err := database.ResetClientReferralBalance(targetUsername); err != nil {
|
||||
utils.ServerErr(c, "Impossible de réinitialiser le solde", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [REFERRAL] Solde parrainage remis à zéro pour %s", targetUsername)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Solde parrainage réinitialisé",
|
||||
"balance": 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GetClientReferralAdmin — GET /api/v2/admin/protected/client/:username/referral (admin)
|
||||
func GetClientReferralAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
balance, err := database.GetClientReferralBalance(targetUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": targetUsername,
|
||||
"balance": balance,
|
||||
})
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /api/v1/app-settings — public, sans auth
|
||||
// Retourne uniquement les flags visibles par clients/cabine (pas les détails de catégories)
|
||||
func GetPublicSettings(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
// En cas d'erreur, retourner les valeurs par défaut
|
||||
settings = db.DefaultSettings()
|
||||
}
|
||||
|
||||
poolNames := make([]string, len(settings.PointsPools))
|
||||
poolKeys := make([]string, len(settings.PointsPools))
|
||||
for i, p := range settings.PointsPools {
|
||||
poolNames[i] = p.Name
|
||||
poolKeys[i] = p.Key
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"penalties_enabled": settings.PenaltiesEnabled,
|
||||
"show_amende_score": settings.ShowAmendeScore,
|
||||
"points_enabled": settings.PointsEnabled,
|
||||
"points_separated": len(settings.PointsPools) > 1,
|
||||
"pool_names": poolNames,
|
||||
"pool_keys": poolKeys,
|
||||
"referral_enabled": settings.ReferralEnabled,
|
||||
"referral_amount": settings.ReferralAmount,
|
||||
"delivery_schedule": settings.DeliverySchedule,
|
||||
"crypto_payment_enabled": settings.CryptoPaymentEnabled,
|
||||
"crypto_only": settings.CryptoOnly,
|
||||
"nowpayments_currencies": settings.NowPaymentsCurrencies,
|
||||
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
|
||||
"shop_name": settings.ShopName,
|
||||
"two_fa_enabled": settings.Telegram2FAEnabled,
|
||||
"contact_telegram": settings.ContactTelegram,
|
||||
"client_color_primary": settings.ClientColorPrimary,
|
||||
"client_color_secondary": settings.ClientColorSecondary,
|
||||
"client_color_success": settings.ClientColorSuccess,
|
||||
"client_color_danger": settings.ClientColorDanger,
|
||||
"client_color_warning": settings.ClientColorWarning,
|
||||
"client_title_gradient_from": settings.ClientTitleGradientFrom,
|
||||
"client_title_gradient_to": settings.ClientTitleGradientTo,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v2/admin/protected/settings
|
||||
func GetSettings(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("❌ [SETTINGS] Erreur lecture: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture paramètres"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "settings": settings})
|
||||
}
|
||||
|
||||
// PUT /api/v2/admin/protected/settings
|
||||
func UpdateSettings(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req models.AppSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Paramètres invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateSettings(req); err != nil {
|
||||
log.Printf("❌ [SETTINGS] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour paramètres"})
|
||||
return
|
||||
}
|
||||
|
||||
// Recharger le service Telegram si le token/username a changé
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.Reload(req.TelegramBotToken, req.TelegramBotUsername)
|
||||
services.TelegramBot.SetNotificationsEnabled(req.TelegramNotificationsEnabled)
|
||||
if req.TelegramBotToken != "" {
|
||||
log.Printf("✅ [SETTINGS] Service Telegram rechargé (username: %s)", req.TelegramBotUsername)
|
||||
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
|
||||
if err := services.TelegramBot.SetWebhook(webhookURL); err != nil {
|
||||
log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", strings.NewReplacer("\n", "", "\r", "").Replace(webhookURL))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "settings": req})
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
|
||||
|
||||
// sections valides pour le reset
|
||||
var validStatsSections = map[string]string{
|
||||
"commandes": "stats_reset_commandes_at",
|
||||
"revenus": "stats_reset_revenus_at",
|
||||
"produits": "stats_reset_produits_at",
|
||||
"heures": "stats_reset_heures_at",
|
||||
"jours": "stats_reset_jours_at",
|
||||
"doses": "stats_reset_doses_at",
|
||||
}
|
||||
|
||||
// ResetAdminStats réinitialise une section précise des statistiques.
|
||||
func ResetAdminStats(c *gin.Context) {
|
||||
section := c.Param("section")
|
||||
key, ok := validStatsSections[section]
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if err := database.ResetAdminStat(key); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Erreur lors de la suppresion de la section statistique: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now})
|
||||
}
|
||||
|
||||
func dateFilter(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// GetAdminStatsByMonth renvoie, pour chaque jour du mois demandé (paramètre
|
||||
// de query "month" au format YYYY-MM, mois courant par défaut), le nombre de
|
||||
// commandes, le revenu et la quantité vendue. Les jours sans commande sont
|
||||
// inclus avec des valeurs à zéro.
|
||||
func GetAdminStatsByMonth(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
monthParam := c.Query("month")
|
||||
monthStart := time.Now()
|
||||
if monthParam != "" {
|
||||
parsed, err := time.Parse("2006-01", monthParam)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre month invalide (attendu YYYY-MM) : %s", monthParam)})
|
||||
return
|
||||
}
|
||||
monthStart = parsed
|
||||
}
|
||||
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
|
||||
|
||||
filters := database.LoadAdminStatsFilters()
|
||||
|
||||
var rows []db.DailyMonthStatRow
|
||||
if err := database.StatsByDayForMonth(&rows, monthStart, filters.ResetCommandes, filters.ResetRevenus); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
rowByDay := make(map[string]db.DailyMonthStatRow, len(rows))
|
||||
for _, r := range rows {
|
||||
rowByDay[r.Day.Format("2006-01-02")] = r
|
||||
}
|
||||
|
||||
daysInMonth := monthStart.AddDate(0, 1, -1).Day()
|
||||
byDay := make([]gin.H, daysInMonth)
|
||||
var totalOrders int
|
||||
var totalRevenue float64
|
||||
var totalQuantity float64
|
||||
|
||||
for i := range daysInMonth {
|
||||
day := monthStart.AddDate(0, 0, i)
|
||||
key := day.Format("2006-01-02")
|
||||
r, ok := rowByDay[key]
|
||||
if !ok {
|
||||
r = db.DailyMonthStatRow{Day: day}
|
||||
}
|
||||
byDay[i] = gin.H{
|
||||
"day": key,
|
||||
"label": day.Format("02/01"),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
"quantity": r.Quantity,
|
||||
}
|
||||
totalOrders += r.Count
|
||||
totalRevenue += r.Revenue
|
||||
totalQuantity += r.Quantity
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"month": monthStart.Format("2006-01"),
|
||||
"summary": gin.H{
|
||||
"total_orders": totalOrders,
|
||||
"total_revenue": totalRevenue,
|
||||
"total_quantity": totalQuantity,
|
||||
},
|
||||
"by_day": byDay,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAdminDailyDetail(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
dateParam := c.Query("date")
|
||||
date := time.Now()
|
||||
if dateParam != "" {
|
||||
parsed, err := time.Parse("2006-01-02", dateParam)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre date invalide (attendu YYYY-MM-DD) : %s", dateParam)})
|
||||
return
|
||||
}
|
||||
date = parsed
|
||||
}
|
||||
|
||||
var dailyRows []models.DailyProductRow
|
||||
if err := database.DailyProductDetailForDate(&dailyRows, date); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération du détail du jour: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
type dailyCatGroup struct {
|
||||
Category string
|
||||
CategoryColor string
|
||||
TotalQuantity float64
|
||||
TotalRevenue float64
|
||||
Products []gin.H
|
||||
}
|
||||
var dailyCats []dailyCatGroup
|
||||
dailyCatIdx := map[string]int{}
|
||||
dailyTotalRevenue := 0.0
|
||||
dailyTotalQty := 0.0
|
||||
|
||||
for _, r := range dailyRows {
|
||||
dailyTotalRevenue += r.Revenue
|
||||
dailyTotalQty += r.TotalQuantity
|
||||
idx, ok := dailyCatIdx[r.Category]
|
||||
if !ok {
|
||||
idx = len(dailyCats)
|
||||
dailyCats = append(dailyCats, dailyCatGroup{
|
||||
Category: r.Category,
|
||||
CategoryColor: r.CategoryColor,
|
||||
})
|
||||
dailyCatIdx[r.Category] = idx
|
||||
}
|
||||
dailyCats[idx].TotalQuantity += r.TotalQuantity
|
||||
dailyCats[idx].TotalRevenue += r.Revenue
|
||||
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
|
||||
"product_id": r.ProductID,
|
||||
"name": r.ProductName,
|
||||
"quantity": r.TotalQuantity,
|
||||
"order_count": r.OrderCount,
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
|
||||
dailyCatsJSON := make([]gin.H, len(dailyCats))
|
||||
for i, g := range dailyCats {
|
||||
dailyCatsJSON[i] = gin.H{
|
||||
"category": g.Category,
|
||||
"category_color": g.CategoryColor,
|
||||
"total_quantity": g.TotalQuantity,
|
||||
"total_revenue": g.TotalRevenue,
|
||||
"products": g.Products,
|
||||
}
|
||||
}
|
||||
|
||||
dailyTotalOrders, _ := database.DailyOrdersCountForDate(date)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"date": date.Format("02/01/2006"),
|
||||
"total_orders": dailyTotalOrders,
|
||||
"total_quantity": dailyTotalQty,
|
||||
"total_revenue": dailyTotalRevenue,
|
||||
"categories": dailyCatsJSON,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
|
||||
func GetAdminStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
filters := database.LoadAdminStatsFilters()
|
||||
|
||||
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
|
||||
var (
|
||||
wdRows []models.WeekdayRow
|
||||
dayRows []models.DayRow
|
||||
dayRevRows []models.DayRevenueRow
|
||||
hourRows []models.HourRow
|
||||
prodRows []models.ProductRow
|
||||
qtyRows []models.QuantityBreakdownRow
|
||||
dailyRows []models.DailyProductRow
|
||||
totalOrders int64
|
||||
totalRevenue float64
|
||||
dailyTotalOrders int64
|
||||
activeDays int64
|
||||
last30Count int64
|
||||
)
|
||||
|
||||
eg, _ := errgroup.WithContext(context.Background())
|
||||
eg.Go(func() error { return database.OrderPerDaysPerWeeks(&wdRows, filters.ResetJours) })
|
||||
eg.Go(func() error { return database.OrdersByDayLast30(&dayRows, filters.ResetCommandes) })
|
||||
eg.Go(func() error { return database.RevenueByDayLast30(&dayRevRows, filters.ResetRevenus) })
|
||||
eg.Go(func() error { return database.OrdersAndRevenueByHour(&hourRows, filters.ResetHeures) })
|
||||
eg.Go(func() error { return database.TopProducts(&prodRows, filters.ResetProduits, 15) })
|
||||
eg.Go(func() error { return database.QuantityBreakdown(&qtyRows, filters.ResetDoses) })
|
||||
eg.Go(func() error { return database.DailyProductDetail(&dailyRows) })
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
totalOrders, err = database.TotalOrders(filters.ResetCommandes)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
dailyTotalOrders, err = database.DailyOrdersCount()
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
activeDays, err = database.ActiveDaysLast30(filters.ResetCommandes)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
last30Count, err = database.OrdersCountLast30(filters.ResetCommandes)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des statistiques"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Commandes par jour de la semaine ──────────────────────────────────────
|
||||
byWeekday := make([]gin.H, 7)
|
||||
wdMap := make(map[int]int, len(wdRows))
|
||||
for _, r := range wdRows {
|
||||
wdMap[r.DOW] = r.Count
|
||||
}
|
||||
peakCount, peakWeekday := 0, ""
|
||||
for i := range 7 {
|
||||
cnt := wdMap[i]
|
||||
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
|
||||
if cnt > peakCount {
|
||||
peakCount = cnt
|
||||
peakWeekday = weekdayNames[i]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
|
||||
byDay := make([]gin.H, len(dayRows))
|
||||
for i, r := range dayRows {
|
||||
byDay[i] = gin.H{
|
||||
"day": r.Day.Format("2006-01-02"),
|
||||
"label": r.Day.Format("02/01"),
|
||||
"count": r.Count,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revenus par jour sur 30 jours ─────────────────────────────────────────
|
||||
byDayRevenue := make([]gin.H, len(dayRevRows))
|
||||
for i, r := range dayRevRows {
|
||||
byDayRevenue[i] = gin.H{
|
||||
"day": r.Day.Format("2006-01-02"),
|
||||
"label": r.Day.Format("02/01"),
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commandes & revenus par heure ─────────────────────────────────────────
|
||||
hourMap := make(map[int]models.HourRow, len(hourRows))
|
||||
for _, r := range hourRows {
|
||||
hourMap[r.Hour] = r
|
||||
}
|
||||
byHour := make([]gin.H, 24)
|
||||
for h := range 24 {
|
||||
r := hourMap[h]
|
||||
byHour[h] = gin.H{
|
||||
"hour": h,
|
||||
"label": fmt.Sprintf("%02dh", h),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top produits ──────────────────────────────────────────────────────────
|
||||
topProducts := make([]gin.H, len(prodRows))
|
||||
topProductName := ""
|
||||
for i, r := range prodRows {
|
||||
topProducts[i] = gin.H{
|
||||
"product_id": r.ProductID,
|
||||
"name": r.Name,
|
||||
"quantity": r.Quantity,
|
||||
"order_count": r.OrderCount,
|
||||
"revenue": r.Revenue,
|
||||
"category": r.Category,
|
||||
"category_color": r.CategoryColor,
|
||||
}
|
||||
if i == 0 {
|
||||
topProductName = r.Name
|
||||
}
|
||||
}
|
||||
|
||||
// ── Répartition des doses/quantités ───────────────────────────────────────
|
||||
type productGroup struct {
|
||||
ProductID int
|
||||
Name string
|
||||
CategoryColor string
|
||||
TotalOrders int
|
||||
Quantities []gin.H
|
||||
}
|
||||
var groups []productGroup
|
||||
groupIdx := map[int]int{}
|
||||
for _, r := range qtyRows {
|
||||
idx, ok := groupIdx[r.ProductID]
|
||||
if !ok {
|
||||
idx = len(groups)
|
||||
groups = append(groups, productGroup{
|
||||
ProductID: r.ProductID,
|
||||
Name: r.ProductName,
|
||||
CategoryColor: r.CategoryColor,
|
||||
})
|
||||
groupIdx[r.ProductID] = idx
|
||||
}
|
||||
groups[idx].TotalOrders += r.OrderCount
|
||||
groups[idx].Quantities = append(groups[idx].Quantities, gin.H{
|
||||
"quantity": r.Quantity,
|
||||
"order_count": r.OrderCount,
|
||||
"total_sold": r.TotalSold,
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
for i := 0; i < len(groups)-1; i++ {
|
||||
for j := i + 1; j < len(groups); j++ {
|
||||
if groups[j].TotalOrders > groups[i].TotalOrders {
|
||||
groups[i], groups[j] = groups[j], groups[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(groups) > 15 {
|
||||
groups = groups[:15]
|
||||
}
|
||||
byQuantity := make([]gin.H, len(groups))
|
||||
for i, grp := range groups {
|
||||
byQuantity[i] = gin.H{
|
||||
"product_id": grp.ProductID,
|
||||
"name": grp.Name,
|
||||
"category_color": grp.CategoryColor,
|
||||
"total_orders": grp.TotalOrders,
|
||||
"quantities": grp.Quantities,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Détail du jour ────────────────────────────────────────────────────────
|
||||
type dailyCatGroup struct {
|
||||
Category string
|
||||
CategoryColor string
|
||||
TotalQuantity float64
|
||||
TotalRevenue float64
|
||||
Products []gin.H
|
||||
}
|
||||
var dailyCats []dailyCatGroup
|
||||
dailyCatIdx := map[string]int{}
|
||||
dailyTotalRevenue := 0.0
|
||||
dailyTotalQty := 0.0
|
||||
for _, r := range dailyRows {
|
||||
dailyTotalRevenue += r.Revenue
|
||||
dailyTotalQty += r.TotalQuantity
|
||||
idx, ok := dailyCatIdx[r.Category]
|
||||
if !ok {
|
||||
idx = len(dailyCats)
|
||||
dailyCats = append(dailyCats, dailyCatGroup{
|
||||
Category: r.Category,
|
||||
CategoryColor: r.CategoryColor,
|
||||
})
|
||||
dailyCatIdx[r.Category] = idx
|
||||
}
|
||||
dailyCats[idx].TotalQuantity += r.TotalQuantity
|
||||
dailyCats[idx].TotalRevenue += r.Revenue
|
||||
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
|
||||
"product_id": r.ProductID,
|
||||
"name": r.ProductName,
|
||||
"quantity": r.TotalQuantity,
|
||||
"order_count": r.OrderCount,
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
dailyCatsJSON := make([]gin.H, len(dailyCats))
|
||||
for i, grp := range dailyCats {
|
||||
dailyCatsJSON[i] = gin.H{
|
||||
"category": grp.Category,
|
||||
"category_color": grp.CategoryColor,
|
||||
"total_quantity": grp.TotalQuantity,
|
||||
"total_revenue": grp.TotalRevenue,
|
||||
"products": grp.Products,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Résumé global ─────────────────────────────────────────────────────────
|
||||
avgPerDay := 0.0
|
||||
if totalOrders > 0 && activeDays > 0 {
|
||||
avgPerDay = float64(last30Count) / float64(activeDays)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"summary": gin.H{
|
||||
"total_orders": totalOrders,
|
||||
"total_revenue": totalRevenue,
|
||||
"peak_weekday": peakWeekday,
|
||||
"top_product": topProductName,
|
||||
"avg_per_day": avgPerDay,
|
||||
},
|
||||
"reset_at_commandes": dateFilter(filters.ResetCommandes),
|
||||
"reset_at_revenus": dateFilter(filters.ResetRevenus),
|
||||
"reset_at_produits": dateFilter(filters.ResetProduits),
|
||||
"reset_at_heures": dateFilter(filters.ResetHeures),
|
||||
"reset_at_jours": dateFilter(filters.ResetJours),
|
||||
"reset_at_doses": dateFilter(filters.ResetDoses),
|
||||
"by_weekday": byWeekday,
|
||||
"by_day_30": byDay,
|
||||
"by_day_revenue": byDayRevenue,
|
||||
"by_hour": byHour,
|
||||
"top_products": topProducts,
|
||||
"by_quantity": byQuantity,
|
||||
"daily_detail": gin.H{
|
||||
"date": time.Now().Format("02/01/2006"),
|
||||
"total_orders": dailyTotalOrders,
|
||||
"total_quantity": dailyTotalQty,
|
||||
"total_revenue": dailyTotalRevenue,
|
||||
"categories": dailyCatsJSON,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TelegramWebhook(c *gin.Context) {
|
||||
// Vérification du secret webhook
|
||||
secret := c.GetHeader("X-Telegram-Bot-Api-Secret-Token")
|
||||
if services.TelegramBot == nil || !services.TelegramBot.ValidateWebhookSecret(secret) {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var update models.TgUpdate
|
||||
if err := c.ShouldBindJSON(&update); err != nil {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if update.Message == nil {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(update.Message.Text)
|
||||
chatID := update.Message.Chat.ID
|
||||
|
||||
// Commande /start <token> — liaison de compte
|
||||
if token, ok := strings.CutPrefix(text, "/start "); ok {
|
||||
token = strings.TrimSpace(token)
|
||||
handleLinkAccount(c, token, chatID)
|
||||
return
|
||||
}
|
||||
|
||||
// Commande /start sans token — message d'accueil
|
||||
if text == "/start" {
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"👋 <b>Bienvenue !</b>\n\nPour lier votre compte, générez un token depuis l'application et envoyez <code>/start <token></code>.")
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func handleLinkAccount(c *gin.Context, token string, chatID int64) {
|
||||
if token == "" {
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID, "❌ Token manquant. Générez un nouveau token depuis l'application.")
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, role, err := db.ValidateAndConsumeLinkToken(token)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM_LINK] Token invalide: %v", err)
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID, "❌ Token invalide ou expiré. Générez un nouveau token depuis l'application.")
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Enregistrer le chat_id selon le rôle
|
||||
var saveErr error
|
||||
switch role {
|
||||
case "client":
|
||||
saveErr = database.SaveClientTelegramChatID(username, chatID)
|
||||
default:
|
||||
saveErr = database.SaveUserTelegramChatID(username, chatID)
|
||||
}
|
||||
|
||||
if saveErr != nil {
|
||||
log.Printf("❌ [TELEGRAM_LINK] Erreur sauvegarde chat_id pour %s (%s): %v", username, role, saveErr)
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID, "❌ Une erreur est survenue. Réessayez.")
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_LINK] Compte %s (%s) lié au chat_id %d", username, role, chatID)
|
||||
|
||||
// Enrollment lbtelegram (best effort — n'empêche pas l'envoi du bouton)
|
||||
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
|
||||
if err := services.LBTelegram.EnrollUser(chatID, username, role); err != nil {
|
||||
log.Printf("⚠️ [LB] enrollment échoué pour %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Message de confirmation — bouton vers BOT1 si lbtelegram configuré, sinon texte simple
|
||||
if services.TelegramBot != nil {
|
||||
if services.LBTelegram != nil && services.LBTelegram.Bot1Username != "" {
|
||||
if err := services.TelegramBot.SendMessageWithButtons(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nPour activer vos notifications, démarrez le bot ci-dessous :",
|
||||
[][2]string{{"🔔 Activer les notifications", "https://t.me/" + services.LBTelegram.Bot1Username}},
|
||||
); err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM] Envoi bouton BOT1 échoué pour %s: %v", username, err)
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||
}
|
||||
} else {
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func GenerateClientLinkToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := db.GenerateLinkToken(username, "client")
|
||||
if err != nil {
|
||||
log.Printf("❌ [TELEGRAM] Erreur génération token pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
func GenerateLivreurLinkToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := db.GenerateLinkToken(username, "livreur")
|
||||
if err != nil {
|
||||
log.Printf("❌ [TELEGRAM] Erreur génération token pour livreur %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
func GenerateAdminLinkToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer le rôle réel depuis la DB
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
user, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Utilisateur introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := db.GenerateLinkToken(username, user.Role)
|
||||
if err != nil {
|
||||
log.Printf("❌ [TELEGRAM] Erreur génération token pour admin %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/telegram/status
|
||||
func GetClientTelegramStatus(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
_, linked, err := database.GetClientTelegramChatID(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur vérification"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"linked": linked,
|
||||
"enabled": services.TelegramBot != nil && services.TelegramBot.IsConfigured(),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/livreur/telegram/status
|
||||
func GetLivreurTelegramStatus(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
_, linked, err := database.GetUserTelegramChatID(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur vérification"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"linked": linked,
|
||||
"enabled": services.TelegramBot != nil && services.TelegramBot.IsConfigured(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DÉLIAISON TELEGRAM
|
||||
// ============================================
|
||||
|
||||
// DELETE /api/v1/telegram/unlink
|
||||
func UnlinkClientTelegram(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if err := database.DeleteClientTelegramChatID(username); err != nil {
|
||||
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
|
||||
return
|
||||
}
|
||||
|
||||
// Désactiver la 2FA si Telegram est délié
|
||||
if clientID > 0 {
|
||||
_ = database.SetClientTwoFAEnabled(clientID, false)
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte client %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/livreur/telegram/unlink
|
||||
func UnlinkLivreurTelegram(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.DeleteUserTelegramChatID(username); err != nil {
|
||||
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte livreur %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DELETE /api/v2/admin/protected/telegram/unlink
|
||||
func UnlinkAdminTelegram(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.DeleteUserTelegramChatID(username); err != nil {
|
||||
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LIAISON INTERNE (appelée par LBTelegram)
|
||||
// ============================================
|
||||
|
||||
// POST /api/internal/telegram/link
|
||||
// Appelée par LBTelegram quand Bot1 reçoit /start TOKEN.
|
||||
// Valide le token, enregistre le chat_id, déclenche l'enrollment.
|
||||
func InternalTelegramLink(c *gin.Context) {
|
||||
secret := c.GetHeader("X-Internal-Secret")
|
||||
expected := os.Getenv("BACKEND_LINK_SECRET")
|
||||
if expected == "" || secret != expected {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ChatID int64 `json:"chat_id" binding:"required"`
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, role, err := db.ValidateAndConsumeLinkToken(req.Token)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM_LINK_INTERNAL] Token invalide: %v", err)
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var saveErr error
|
||||
switch role {
|
||||
case "client":
|
||||
saveErr = database.SaveClientTelegramChatID(username, req.ChatID)
|
||||
default:
|
||||
saveErr = database.SaveUserTelegramChatID(username, req.ChatID)
|
||||
}
|
||||
if saveErr != nil {
|
||||
log.Printf("❌ [TELEGRAM_LINK_INTERNAL] Erreur sauvegarde pour %s: %v", username, saveErr)
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_LINK_INTERNAL] Compte %s (%s) lié via Bot1 (chat_id %d)", username, role, req.ChatID)
|
||||
|
||||
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
|
||||
if err := services.LBTelegram.EnrollUser(req.ChatID, username, role); err != nil {
|
||||
log.Printf("⚠️ [LB] enrollment échoué pour %s: %v", username, err)
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// ============================================
|
||||
// handlers/traffic_handlers.go - COMPLET
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// getFloatFromMap récupère un float64 depuis une map avec différents types
|
||||
func getFloatFromMap(m map[string]any, key string) (float64, bool) {
|
||||
value, exists := m[key]
|
||||
if !exists || value == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case json.Number:
|
||||
f, err := v.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case []byte:
|
||||
s := string(v)
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
// ============================================
|
||||
// handlers/profile_handlers.go - VERSION CORRIGÉE
|
||||
// ============================================
|
||||
// Gestion des modifications de profils
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// MODIFICATION PROFIL CLIENT (PAR LE CLIENT)
|
||||
// ============================================
|
||||
|
||||
// UpdateMyProfile permet à un client de modifier son propre profil
|
||||
// PUT /api/v1/profile/update
|
||||
func UpdateMyProfile(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req models.UpdateClientProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer le client actuel
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si des modifications sont demandées
|
||||
hasChanges := false
|
||||
|
||||
// Mise à jour du username
|
||||
if req.Username != "" {
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
}
|
||||
if req.Username != "" && req.Username != client.Username {
|
||||
// Vérifier que le nouveau username n'existe pas (clients et users)
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Username réservé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Username = req.Username
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du mot de passe
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
client.Password = string(hashed)
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du nom
|
||||
if req.Nom != "" {
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
}
|
||||
if req.Nom != "" && req.Nom != client.Nom {
|
||||
if len(req.Nom) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit contenir au moins 2 caractères"})
|
||||
return
|
||||
}
|
||||
client.Nom = req.Nom
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du prénom
|
||||
if req.Prenom != "" {
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
}
|
||||
if req.Prenom != "" && req.Prenom != client.Prenom {
|
||||
if len(req.Prenom) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le prénom doit contenir au moins 2 caractères"})
|
||||
return
|
||||
}
|
||||
client.Prenom = req.Prenom
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du téléphone
|
||||
if req.Telephone != "" && req.Telephone != client.Telephone {
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone invalide: %s", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
|
||||
return
|
||||
}
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
|
||||
// Vérifier que le téléphone n'est pas déjà utilisé
|
||||
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone déjà utilisé: %s", normalizedPhone)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Telephone = normalizedPhone
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Aucune modification détectée",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder les modifications
|
||||
if err := database.UpdateClient(client); err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [UPDATE_MY_PROFILE] Profil mis à jour: %s (ID=%d)", client.Username, client.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Profil mis à jour avec succès",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyProfile retourne le profil du client connecté
|
||||
// GET /api/v1/profile
|
||||
func GetMyProfile(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
client, err := database.GetClientByUsername(username.(string))
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "client": sanitizeClient(client)})
|
||||
}
|
||||
|
||||
// UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
|
||||
// PUT /api/v2/admin/protected/clients/:id
|
||||
func UpdateClientByAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès réservé aux administrateurs",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'ID du client à modifier
|
||||
clientIDStr := c.Param("id")
|
||||
clientID, err := strconv.Atoi(clientIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID client invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AdminUpdateClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer le client actuel
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ LOG DEBUG - État initial
|
||||
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État initial - Command: %d, Amende: %.2f",
|
||||
client.Command, client.Amende)
|
||||
|
||||
// Vérifier si des modifications sont demandées
|
||||
hasChanges := false
|
||||
|
||||
// Mise à jour du username
|
||||
if req.Username != "" {
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
}
|
||||
if req.Username != "" && req.Username != client.Username {
|
||||
// Vérifier que le nouveau username n'existe pas
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Username = req.Username
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Username modifié: %s", req.Username)
|
||||
}
|
||||
|
||||
// Mise à jour du mot de passe (opération séparée pour garantir l'écriture)
|
||||
var newHashedPassword string
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
newHashedPassword = string(hashed)
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Mot de passe modifié")
|
||||
}
|
||||
|
||||
// Mise à jour du nom
|
||||
if req.Nom != "" {
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
}
|
||||
if req.Nom != "" && req.Nom != client.Nom {
|
||||
client.Nom = req.Nom
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Nom modifié: %s", req.Nom)
|
||||
}
|
||||
|
||||
// Mise à jour du prénom
|
||||
if req.Prenom != "" {
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
}
|
||||
if req.Prenom != "" && req.Prenom != client.Prenom {
|
||||
client.Prenom = req.Prenom
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Prénom modifié: %s", req.Prenom)
|
||||
}
|
||||
|
||||
// Mise à jour du téléphone
|
||||
if req.Telephone != "" && req.Telephone != client.Telephone {
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone invalide: %s", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
|
||||
return
|
||||
}
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
|
||||
// Vérifier que le téléphone n'est pas déjà utilisé
|
||||
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone déjà utilisé: %s", normalizedPhone)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Telephone = normalizedPhone
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Téléphone modifié: %s", normalizedPhone)
|
||||
}
|
||||
|
||||
if req.Command != nil && *req.Command != client.Command {
|
||||
client.Command = *req.Command
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Commandes modifiées: %d → %d", client.Command, *req.Command)
|
||||
}
|
||||
|
||||
if req.Amende != nil {
|
||||
if *req.Amende < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "L'amende ne peut pas être négative"})
|
||||
return
|
||||
}
|
||||
if *req.Amende != client.Amende {
|
||||
client.Amende = *req.Amende
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Amendes modifiées: %.2f → %.2f", client.Amende, *req.Amende)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
log.Printf("ℹ️ [UPDATE_CLIENT_ADMIN] Aucune modification détectée pour client ID=%d", clientID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Aucune modification détectée",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État avant save - Command: %d, Amende: %.2f",
|
||||
client.Command, client.Amende)
|
||||
|
||||
// Sauvegarder les modifications de profil (hors mot de passe)
|
||||
if err := database.UpdateClient(client); err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour profil: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour le mot de passe séparément si demandé
|
||||
if newHashedPassword != "" {
|
||||
if err := database.UpdateClientPasswordAndClearFlag(clientID, newHashedPassword); err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour mot de passe: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour du mot de passe"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Mot de passe mis à jour pour client ID=%d", clientID)
|
||||
}
|
||||
|
||||
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Client mis à jour par admin: %s (ID=%d)", client.Username, client.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Client mis à jour avec succès",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
|
||||
// PUT /api/v2/admin/protected/users/:id
|
||||
func UpdateUserByAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if c.GetString("role") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès réservé aux administrateurs",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Récupérer l'ID de l'utilisateur à modifier
|
||||
userIDStr := c.Param("id")
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID utilisateur invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateUserProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
user, err := database.GetUserByID(userID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] User non trouvé: ID=%d", userID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si des modifications sont demandées
|
||||
hasChanges := false
|
||||
|
||||
// Mise à jour du username
|
||||
if req.Username != "" {
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
}
|
||||
if req.Username != "" && req.Username != user.Username {
|
||||
// Vérifier que le nouveau username n'existe pas (users et clients)
|
||||
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Username réservé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
user.Username = req.Username
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du mot de passe
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
user.Password = string(hashed)
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du rôle
|
||||
if req.Role != "" && req.Role != user.Role {
|
||||
// Valider le rôle
|
||||
validRoles := map[string]bool{
|
||||
"admin": true,
|
||||
"cabine": true,
|
||||
"livreur": true,
|
||||
}
|
||||
if !validRoles[req.Role] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Rôle invalide. Valeurs acceptées: admin, cabine, livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
user.Role = req.Role
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Aucune modification détectée",
|
||||
"user": sanitizeUser(user),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder les modifications
|
||||
if err := database.UpdateUser(user); err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [UPDATE_USER_ADMIN] User mis à jour par admin: %s (ID=%d, Role=%s)", user.Username, user.ID, user.Role)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Utilisateur mis à jour avec succès",
|
||||
"user": sanitizeUser(user),
|
||||
})
|
||||
}
|
||||
|
||||
func sanitizeClient(client *models.Client) gin.H {
|
||||
return gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"command": client.Command,
|
||||
"points_extra": client.PointsExtra,
|
||||
"amende": client.Amende,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeUser(user *models.User) gin.H {
|
||||
return gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
|
||||
// ============================================
|
||||
|
||||
// StartDelivery permet au livreur de démarrer une livraison (passage en in_transit)
|
||||
// POST /api/v1/deliveries/:id/start
|
||||
func StartDelivery(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists || c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🚗 [START] %s démarre livraison cmd %d", usernameStr, commandID)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier propriété
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != usernameStr {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier le statut actuel
|
||||
currentStatus, _ := command["status"].(string)
|
||||
if currentStatus != "assigned" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de démarrer cette livraison",
|
||||
"current_status": currentStatus,
|
||||
"message": "La commande doit être en statut 'assigned'",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour le statut en "en_route"
|
||||
if err := database.UpdateCommandStatus(commandID, "en_route"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour statut",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour la position du livreur
|
||||
database.UpdateLivreurPosition(usernameStr, req.Latitude, req.Longitude, "busy")
|
||||
|
||||
// Mettre à jour le statut du livreur
|
||||
database.SetDeliveryPersonStatus(usernameStr, "busy", commandID)
|
||||
|
||||
// Ajouter un log
|
||||
database.AddCommandLog(commandID, "en_route",
|
||||
fmt.Sprintf("Livraison démarrée par %s", usernameStr),
|
||||
usernameStr)
|
||||
|
||||
// Notifier le client
|
||||
if clientUsername, _ := command["username"].(string); clientUsername != "" {
|
||||
var msg string
|
||||
etaMinutes := 0
|
||||
if etaData, err := database.GetCommandETA(commandID); err == nil {
|
||||
if v, ok := etaData["total_eta_minutes"]; ok {
|
||||
if n, err2 := strconv.Atoi(v); err2 == nil && n > 0 {
|
||||
etaMinutes = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if etaMinutes == 0 {
|
||||
destLat, _ := command["dest_latitude"].(float64)
|
||||
destLon, _ := command["dest_longitude"].(float64)
|
||||
|
||||
// Fallback 1 : cache Redis (géocodage déjà fait à l'assignation
|
||||
// mais pas encore persisté en DB — cf. goroutine async dans
|
||||
// handlers/commands.go AssignCommandToDeliveryman).
|
||||
if destLat == 0 || destLon == 0 {
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
if destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result(); err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||
destLat, destLon = coords.Lat, coords.Lon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 2 : géocodage synchrone de l'adresse. Couvre le cas où
|
||||
// le livreur démarre la livraison avant que la goroutine async
|
||||
// d'assignation ait fini de géocoder (race condition).
|
||||
if (destLat == 0 || destLon == 0) && geoService != nil {
|
||||
if adresse, _ := command["adresse"].(string); adresse != "" {
|
||||
if location, err := geoService.GeocodeAddress(adresse); err == nil && location != nil {
|
||||
destLat, destLon = location.Latitude, location.Longitude
|
||||
database.GDB.Exec(
|
||||
"UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
|
||||
destLat, destLon, commandID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if destLat != 0 && destLon != 0 {
|
||||
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
|
||||
} else {
|
||||
// Fallback 3 : aucune coordonnée exploitable — ETA par
|
||||
// défaut plutôt que pas d'ETA du tout dans le message.
|
||||
etaMinutes = 30
|
||||
}
|
||||
}
|
||||
if etaMinutes > 0 {
|
||||
var etaStr string
|
||||
if etaMinutes >= 60 {
|
||||
h := etaMinutes / 60
|
||||
m := etaMinutes % 60
|
||||
if m > 0 {
|
||||
etaStr = fmt.Sprintf("%dh%02d", h, m)
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%dh", h)
|
||||
}
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%d min", etaMinutes)
|
||||
}
|
||||
msg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route (~%s)", database.GetClientOrderID(commandID), etaStr)
|
||||
} else {
|
||||
msg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route.", database.GetClientOrderID(commandID))
|
||||
}
|
||||
database.NotifyClient(clientUsername, commandID, "en_route", msg)
|
||||
}
|
||||
|
||||
log.Printf("✅ [START] Livraison %d démarrée", commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Livraison démarrée",
|
||||
"command_id": commandID,
|
||||
"status": "en_route",
|
||||
})
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var postalCodeRe = regexp.MustCompile(`\b(\d{5})\b`)
|
||||
|
||||
// postalSet convertit une slice de codes en set pour lookup O(1).
|
||||
func postalSet(codes []string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(codes))
|
||||
for _, c := range codes {
|
||||
m[c] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// extractPostalCode extrait le premier code postal à 5 chiffres d'une adresse.
|
||||
func extractPostalCode(address string) string {
|
||||
m := postalCodeRe.FindStringSubmatch(address)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// zoneCheckResult est le résultat de la vérification de zone.
|
||||
type zoneCheckResult struct {
|
||||
PostalCode string
|
||||
ZoneName string
|
||||
MinAmount float64
|
||||
OK bool
|
||||
}
|
||||
|
||||
// checkDeliveryZone vérifie si le total respecte le minimum de la zone de l'adresse.
|
||||
// Les zones sont lues depuis la DB (settings.PostalZones).
|
||||
// Code postal introuvable → OK = false (refus).
|
||||
// Code postal hors de toutes les zones → OK = false (refus).
|
||||
func checkDeliveryZone(deliveryAddress string, total float64, zones []models.PostalZone) zoneCheckResult {
|
||||
code := extractPostalCode(deliveryAddress)
|
||||
if code == "" {
|
||||
return zoneCheckResult{PostalCode: "", ZoneName: "inconnue", MinAmount: 0, OK: false}
|
||||
}
|
||||
|
||||
for _, zone := range zones {
|
||||
set := postalSet(zone.Codes)
|
||||
if _, found := set[code]; found {
|
||||
return zoneCheckResult{
|
||||
PostalCode: code,
|
||||
ZoneName: zone.Name,
|
||||
MinAmount: zone.MinAmount,
|
||||
OK: total >= zone.MinAmount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return zoneCheckResult{PostalCode: code, ZoneName: "hors zone", MinAmount: 0, OK: false}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/routes"
|
||||
"gestion/services"
|
||||
"gestion/workers"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if loc, err := time.LoadLocation("Europe/Paris"); err == nil {
|
||||
time.Local = loc
|
||||
} else {
|
||||
log.Printf("⚠️ Impossible de charger la timezone Europe/Paris: %v", err)
|
||||
}
|
||||
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("⚠️ Aucun fichier .env trouvé, utilisation des valeurs par défaut.")
|
||||
}
|
||||
|
||||
database := db.InitDB()
|
||||
defer database.Close()
|
||||
log.Printf("✅ Database initialisée: %+v", database)
|
||||
db.InitRedis()
|
||||
defer db.Redis.Close()
|
||||
log.Println("✅ Redis initialisé avec succès")
|
||||
|
||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
log.Println("✅ Service de géolocalisation initialisé")
|
||||
|
||||
lbService := services.NewLBTelegramService()
|
||||
if lbService.IsConfigured() {
|
||||
log.Println("✅ Service LBTelegram initialisé")
|
||||
} else {
|
||||
log.Println("ℹ️ Service LBTelegram désactivé (LBTELEGRAM_URL non défini)")
|
||||
}
|
||||
|
||||
telegramService := services.NewTelegramService()
|
||||
if telegramService.IsConfigured() {
|
||||
log.Println("✅ Service Telegram initialisé")
|
||||
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
|
||||
if err := telegramService.SetWebhook(webhookURL); err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM] Erreur enregistrement webhook: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Println("ℹ️ Service Telegram désactivé (TELEGRAM_BOT_TOKEN non défini)")
|
||||
}
|
||||
|
||||
database.MigrateAddTelegramColumns()
|
||||
|
||||
if dbSettings, err := database.GetSettings(); err == nil {
|
||||
telegramService.Reload(dbSettings.TelegramBotToken, dbSettings.TelegramBotUsername)
|
||||
telegramService.SetNotificationsEnabled(dbSettings.TelegramNotificationsEnabled)
|
||||
if dbSettings.TelegramBotToken != "" {
|
||||
log.Printf("✅ [TELEGRAM] Config chargée depuis la DB (username: %s)", dbSettings.TelegramBotUsername)
|
||||
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
|
||||
if err := telegramService.SetWebhook(webhookURL); err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM] Erreur enregistrement webhook (DB reload): %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ré-enrôler tous les comptes déjà liés dans lbtelegram (au cas où lbtelegram a redémarré)
|
||||
if lbService.IsConfigured() {
|
||||
go func() {
|
||||
accounts, err := database.GetAllLinkedTelegramAccounts()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [LB_SYNC] Erreur lecture comptes liés: %v", err)
|
||||
return
|
||||
}
|
||||
ok, fail := 0, 0
|
||||
for _, a := range accounts {
|
||||
if err := lbService.EnrollUser(a.ChatID, a.Username, a.Role); err != nil {
|
||||
fail++
|
||||
} else {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
log.Printf("✅ [LB_SYNC] Re-enrollment terminé: %d OK, %d échecs (total %d comptes)", ok, fail, len(accounts))
|
||||
}()
|
||||
}
|
||||
|
||||
s3Service, err := services.NewS3Service(
|
||||
os.Getenv("S3_REGION"),
|
||||
os.Getenv("S3_BUCKET"),
|
||||
os.Getenv("S3_ENDPOINT"),
|
||||
services.S3Credentials{
|
||||
S3KeyId: os.Getenv("RUSTFS_ACCESS_KEY"),
|
||||
S3AccessKey: os.Getenv("RUSTFS_SECRET_KEY"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("erreur init S3: %v", err)
|
||||
}
|
||||
|
||||
var storage services.Storage
|
||||
switch os.Getenv("STORAGE_DRIVER") {
|
||||
case "s3":
|
||||
storage = services.NewS3Storage(s3Service)
|
||||
log.Println("✅ Storage driver: s3 (RustFS)")
|
||||
default:
|
||||
storage = services.NewLocalStorage("uploads")
|
||||
log.Println("✅ Storage driver: local")
|
||||
}
|
||||
|
||||
log.Println("")
|
||||
log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
|
||||
removed, err := database.CleanupInvalidQueueCommands()
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur lors du nettoyage initial: %v", err)
|
||||
} else {
|
||||
if removed > 0 {
|
||||
log.Printf("✅ Nettoyage initial terminé: %d commandes invalides supprimées", removed)
|
||||
} else {
|
||||
log.Println("✅ Nettoyage initial: Aucune commande invalide trouvée")
|
||||
}
|
||||
}
|
||||
|
||||
go database.StartQueueCleanupScheduler()
|
||||
log.Println("✅ Scheduler de nettoyage démarré (toutes les 5 min)")
|
||||
|
||||
if err := database.SyncAllDeliverymanStatuses(); err != nil {
|
||||
log.Printf("⚠️ Erreur synchronisation statuts: %v", err)
|
||||
} else {
|
||||
log.Println("✅ Synchronisation des statuts livreurs terminée")
|
||||
}
|
||||
|
||||
go workers.StartAutoAssignmentCron(database, geoService)
|
||||
log.Println("✅ Cron job auto-assignation démarré (5 min)")
|
||||
|
||||
go workers.StartRedisWorkers(database)
|
||||
log.Println("✅ Workers Redis démarrés")
|
||||
|
||||
workers.StartDynamicPaymentChecker(database, func() *services.NowPaymentsClient {
|
||||
s, err := database.GetSettings()
|
||||
if err != nil || !s.CryptoPaymentEnabled || s.NowPaymentsAPIKey == "" {
|
||||
return nil
|
||||
}
|
||||
return services.NewNowPaymentsClient(s.NowPaymentsAPIKey, s.NowPaymentsIPNSecret)
|
||||
}, 2*time.Minute)
|
||||
log.Println("✅ Worker paiements crypto démarré (2 min)")
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
|
||||
store := cookie.NewStore([]byte(os.Getenv("SESSION_SECRET")))
|
||||
store.Options(sessions.Options{
|
||||
Path: "/",
|
||||
Domain: "",
|
||||
MaxAge: 3600,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
r.Use(sessions.Sessions("mysession", store))
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"https://uber-stup.club", "https://5.181.0.112.nip.io", "https://5.181.0.112.nip.io:8080", "https://5.181.0.112.nip.io:8443", "https://mln-uber.club", "http://localhost:5173", "http://5.181.0.112"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("database", database)
|
||||
c.Set("geoService", geoService)
|
||||
c.Set("s3Service", s3Service)
|
||||
c.Set("storage", storage)
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
settings, err := database.GetSettings()
|
||||
if err == nil && settings.CryptoPaymentEnabled && settings.NowPaymentsAPIKey != "" {
|
||||
np := services.NewNowPaymentsClient(settings.NowPaymentsAPIKey, settings.NowPaymentsIPNSecret)
|
||||
c.Set("nowpayments", np)
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
routes.SetupRoutes(r, database, geoService, s3Service)
|
||||
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func BlockClientIfPenalty(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if settings, err := database.GetSettings(); err == nil && !settings.PenaltiesEnabled {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
clientID, exists := c.Get("client_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if id, ok := clientID.(int); ok {
|
||||
if session, err := database.GetClientSession(id); err == nil && session.PenaltyCache > 0 {
|
||||
log.Printf("🚫 [PENALTY] Checkout bloqué pour client_id=%d (amende=%.2f via cache)", id, session.PenaltyCache)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commande bloquée : vous avez une amende en attente de paiement",
|
||||
"amende": session.PenaltyCache,
|
||||
"blocked": true,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr, ok := username.(string)
|
||||
if !ok || usernameStr == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
amende, err := database.GetClientAmende(usernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [PENALTY] Erreur vérification amende pour %s: %v", usernameStr, err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if amende > 0 {
|
||||
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": fmt.Sprintf("Commande bloquée : vous avez une amende de %.0f€ en attente de paiement. Prenez attache avec Milieu Nantais sur signal pour régulariser votre situation..", amende),
|
||||
"amende": amende,
|
||||
"blocked": true,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func OrderHoursMiddleware(c *gin.Context) {
|
||||
loc, err := time.LoadLocation("Europe/Paris")
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
weekday := now.Weekday()
|
||||
hour := now.Hour()
|
||||
min := now.Minute()
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLOCK-MWARE] Erreur lecture settings: %v — accès autorisé par défaut", err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
sched := settings.DeliverySchedule
|
||||
var day models.DaySchedule
|
||||
switch weekday {
|
||||
case time.Monday:
|
||||
day = sched.Monday
|
||||
case time.Tuesday:
|
||||
day = sched.Tuesday
|
||||
case time.Wednesday:
|
||||
day = sched.Wednesday
|
||||
case time.Thursday:
|
||||
day = sched.Thursday
|
||||
case time.Friday:
|
||||
day = sched.Friday
|
||||
case time.Saturday:
|
||||
day = sched.Saturday
|
||||
case time.Sunday:
|
||||
day = sched.Sunday
|
||||
}
|
||||
|
||||
if !day.Enabled {
|
||||
log.Printf("❌ [CLOCK-MWARE] Commande refusée — jour fermé (%s)", weekday)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commandes non disponibles aujourd'hui",
|
||||
"message": "La livraison n'est pas disponible ce jour",
|
||||
"current_time": now.Format("15:04"),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parseTime := func(t string) int {
|
||||
var h, m int
|
||||
fmt.Sscanf(t, "%d:%d", &h, &m)
|
||||
return h*60 + m
|
||||
}
|
||||
|
||||
currentMinutes := hour*60 + min
|
||||
openMinutes := parseTime(day.OpenTime)
|
||||
closeMinutes := parseTime(day.CloseTime)
|
||||
|
||||
if currentMinutes < openMinutes || currentMinutes >= closeMinutes {
|
||||
log.Printf("❌ [CLOCK-MWARE] Commande refusée à %02d:%02d (plage autorisée: %s - %s)", hour, min, day.OpenTime, day.CloseTime)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commandes non disponibles à cette heure",
|
||||
"message": fmt.Sprintf("Vous pouvez commander entre %s et %s", day.OpenTime, day.CloseTime),
|
||||
"current_time": now.Format("15:04"),
|
||||
"open_at": day.OpenTime,
|
||||
"close_at": day.CloseTime,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CLOCK-MWARE] Commande autorisée à %02d:%02d", hour, min)
|
||||
c.Next()
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
// ============================================
|
||||
// middleware/middleware_CORRIGES.go
|
||||
// ============================================
|
||||
// Réorganisation complète des middlewares
|
||||
// Déplace ClientMiddleware, AdminMiddleware, etc depuis handlers/auth.go
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// TYPES JWT CLAIMS
|
||||
// ============================================
|
||||
|
||||
type ClientClaims struct {
|
||||
ClientID int `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type AdminClaims struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VARIABLES GLOBALES
|
||||
// ============================================
|
||||
|
||||
var (
|
||||
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients
|
||||
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET")) // ✅ Pour admin/cabine/livreur
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// VALIDATION TOKENS
|
||||
// ============================================
|
||||
|
||||
// validateClientToken valide un token client
|
||||
func validateClientToken(tokenString string) (*ClientClaims, error) {
|
||||
tokenString = strings.TrimSpace(tokenString)
|
||||
if tokenString == "" {
|
||||
return nil, fmt.Errorf("token vide")
|
||||
}
|
||||
|
||||
log.Printf("🔍 [VALIDATE-CLIENT] Validating client token...")
|
||||
|
||||
// Parser JWT EN PREMIER avec userJWTSecret (CLIENT)
|
||||
token, err := jwt.ParseWithClaims(tokenString, &ClientClaims{}, func(token *jwt.Token) (any, error) {
|
||||
// Vérifier explicitement l'algorithme
|
||||
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing algorithm: %v", token.Method.Alg())
|
||||
}
|
||||
return userJWTSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [VALIDATE-CLIENT] JWT parse error: %v", err)
|
||||
return nil, fmt.Errorf("jwt parsing failed: %v", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
log.Printf("❌ [VALIDATE-CLIENT] Token not valid")
|
||||
return nil, fmt.Errorf("token not valid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*ClientClaims)
|
||||
if !ok {
|
||||
log.Printf("❌ [VALIDATE-CLIENT] Claims type error")
|
||||
return nil, fmt.Errorf("invalid claims type")
|
||||
}
|
||||
if claims.Role == "" {
|
||||
return nil, fmt.Errorf("role manquant dans le token")
|
||||
}
|
||||
if claims.Issuer != "api-client" {
|
||||
return nil, fmt.Errorf("issuer invalide")
|
||||
}
|
||||
|
||||
if claims.ExpiresAt.Unix() < time.Now().Unix() {
|
||||
return nil, fmt.Errorf("token expiré")
|
||||
}
|
||||
|
||||
log.Printf("✅ [VALIDATE-CLIENT] JWT valid - Username: %s, ClientID: %d", claims.Username, claims.ClientID)
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// validateAdminToken valide un token admin
|
||||
func validateAdminToken(tokenString string) (*AdminClaims, error) {
|
||||
tokenString = strings.TrimSpace(tokenString)
|
||||
if tokenString == "" {
|
||||
return nil, fmt.Errorf("token vide")
|
||||
}
|
||||
|
||||
log.Printf("🔍 [VALIDATE-ADMIN] Validating admin token...")
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &AdminClaims{}, func(token *jwt.Token) (any, error) {
|
||||
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing algorithm: %v", token.Method.Alg())
|
||||
}
|
||||
return adminJWTSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [VALIDATE-ADMIN] JWT parse error: %v", err)
|
||||
return nil, fmt.Errorf("jwt parsing failed: %v", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
log.Printf("❌ [VALIDATE-ADMIN] Token not valid")
|
||||
return nil, fmt.Errorf("token not valid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*AdminClaims)
|
||||
if !ok {
|
||||
log.Printf("❌ [VALIDATE-ADMIN] Claims type error")
|
||||
return nil, fmt.Errorf("invalid claims type")
|
||||
}
|
||||
if claims.Role == "" {
|
||||
return nil, fmt.Errorf("role manquant dans le token")
|
||||
}
|
||||
if claims.Issuer != "api-admin" {
|
||||
return nil, fmt.Errorf("issuer invalide")
|
||||
}
|
||||
|
||||
if claims.ExpiresAt.Unix() < time.Now().Unix() {
|
||||
return nil, fmt.Errorf("token expiré")
|
||||
}
|
||||
log.Printf("✅ [VALIDATE-ADMIN] JWT valid - Username: %s, Role: %s, UserID: %d Issuer: %s ExpiresAt: %d",
|
||||
claims.Username, claims.Role, claims.UserID, claims.Issuer, claims.ExpiresAt.Unix())
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func ClientMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [CLIENT-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateClientToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLIENT-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [CLIENT-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("client_id", claims.ClientID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [CLIENT-MWARE] Client %s (ID=%d) authentifié", claims.Username, claims.ClientID)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func AdminMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [ADMIN-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADMIN-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [ADMIN-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier rôle — admin uniquement
|
||||
if claims.Role != "admin" {
|
||||
log.Printf("❌ [ADMIN-MWARE] Role invalide: %s (admin requis)", claims.Role)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès admin requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [ADMIN-MWARE] Admin %s (Role=%s, ID=%d) authentifié",
|
||||
claims.Username, claims.Role, claims.UserID)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func CabineMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [CABINE-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Check révocation
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CABINE-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [CABINE-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier rôle — admin ou cabine uniquement
|
||||
if claims.Role != "admin" && claims.Role != "cabine" {
|
||||
log.Printf("❌ [CABINE-MWARE] Role non autorisé: %s", claims.Role)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès cabine requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Stocker les infos
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [CABINE-MWARE] Cabine %s (%s) authentifiée", claims.Username, claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func LivreurMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Check révocation
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier rôle — admin ou livreur uniquement
|
||||
if claims.Role != "admin" && claims.Role != "livreur" {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Role non autorisé: %s", claims.Role)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès livreur requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Stocker les infos
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [LIVREUR-MWARE] Livreur %s (%s) authentifié", claims.Username, claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func ClientSessionMiddleware(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Récupérer le username du JWT (DÉJÀ VALIDÉ)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [SESSION-MWARE] Username manquant du JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "JWT invalide - username manquant",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
// Récupérer le client_id du JWT
|
||||
clientID, ok := c.Get("client_id")
|
||||
if !ok {
|
||||
log.Printf("❌ [SESSION-MWARE] client_id manquant du JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "JWT invalide - client_id manquant",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
clientIDInt, ok := clientID.(int)
|
||||
if !ok {
|
||||
log.Printf("❌ [SESSION-MWARE] client_id malformé: %v", clientID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "JWT invalide - client_id malformé",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier la session Redis
|
||||
session, err := database.GetClientSession(clientIDInt)
|
||||
if err != nil {
|
||||
log.Printf("❌ [SESSION-MWARE] Pas de session Redis pour client %d: %v", clientIDInt, err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Session expirée - Veuillez vous reconnecter",
|
||||
"action": "Please login again",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que les données correspondent
|
||||
if session.Username != usernameStr {
|
||||
log.Printf("❌ [SESSION-MWARE] MISMATCH! JWT=%s, session=%s", usernameStr, session.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Session invalide - Mismatch détecté",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if session.ClientID != clientIDInt {
|
||||
log.Printf("❌ [SESSION-MWARE] MISMATCH! JWT=%d, session=%d", clientIDInt, session.ClientID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Session invalide - client_id mismatch",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Prolonger la session
|
||||
if err := database.RefreshSessionTimeout(clientIDInt); err != nil {
|
||||
log.Printf("⚠️ [SESSION-MWARE] Erreur refresh: %v", err)
|
||||
}
|
||||
|
||||
// Charger les infos dans le contexte
|
||||
c.Set("session_id", session.SessionID)
|
||||
c.Set("session", session)
|
||||
c.Set("last_activity", session.LastActivity)
|
||||
|
||||
log.Printf("✅ [SESSION-MWARE] Session valide pour %s (client_id=%d)", usernameStr, clientIDInt)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func RateLimitMiddleware(c *gin.Context) {
|
||||
// Récupérer le client_id
|
||||
clientID, ok := c.Get("client_id")
|
||||
if !ok {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
clientIDInt := clientID.(int)
|
||||
rateLimitKey := "ratelimit:" + strconv.Itoa(clientIDInt)
|
||||
|
||||
// Incrémenter le compteur
|
||||
count, err := db.Redis.Incr(db.RedisCtx, rateLimitKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [RATELIMIT] Erreur: %v", err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
db.Redis.Expire(db.RedisCtx, rateLimitKey, 60*time.Second) // 1 minute
|
||||
}
|
||||
|
||||
if count > 100 {
|
||||
log.Printf("❌ [RATELIMIT] Client %d dépassé le limite: %d requêtes/min", clientIDInt, count)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Trop de requêtes - Réessayez dans une minute",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("X-RateLimit-Remaining", strconv.FormatInt(100-count, 10))
|
||||
|
||||
log.Printf("📊 [RATELIMIT] Client %d: %d/%d requêtes", clientIDInt, count, 100)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// LoginRateLimitMiddleware limite les tentatives de connexion par IP.
|
||||
// Config: 10 tentatives par 15 minutes.
|
||||
func LoginRateLimitMiddleware(c *gin.Context) {
|
||||
|
||||
ip := c.ClientIP()
|
||||
|
||||
rateLimitKey := "ratelimit:login:" + ip
|
||||
|
||||
count, err := db.Redis.Incr(db.RedisCtx, rateLimitKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [LOGIN-RATELIMIT] Erreur Redis: %v", err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
db.Redis.Expire(db.RedisCtx, rateLimitKey, 15*time.Minute)
|
||||
}
|
||||
|
||||
if count > 10 {
|
||||
log.Printf("❌ [LOGIN-RATELIMIT] IP %s bloquée: %d tentatives/15min", ip, count)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Trop de tentatives de connexion - Réessayez dans 15 minutes",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10))
|
||||
c.Next()
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Address struct {
|
||||
ID int64 `db:"id" gorm:"primaryKey;autoIncrement"`
|
||||
InvalidAddress string `db:"invalid_address" gorm:"column:invalid_address"`
|
||||
CorrectAddress string `db:"correct_address" gorm:"column:correct_address"`
|
||||
CreatedAt time.Time `db:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `db:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (Address) TableName() string { return "adresse_correction" }
|
||||
@@ -1,14 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type AlertPolicy struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username" gorm:"column:username"`
|
||||
Status string `json:"status" gorm:"column:status"`
|
||||
Message string `json:"message" gorm:"column:message"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (AlertPolicy) TableName() string { return "alerte_policy" }
|
||||
@@ -1,54 +0,0 @@
|
||||
package models
|
||||
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
type ClientClaims struct {
|
||||
ClientID int `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type AdminClaims struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// STRUCTURES REQUÊTE / RÉPONSE
|
||||
// ============================================
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterClientRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Nom string `json:"nom" binding:"required,min=2,max=100"`
|
||||
Prenom string `json:"prenom" binding:"required,min=2,max=100"`
|
||||
Telephone string `json:"telephone" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterAdminRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Role string `json:"role" binding:"required,oneof=cabine livreur"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User any `json:"user"`
|
||||
}
|
||||
|
||||
type ProfileResponse struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// models/client.go
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Client struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"column:username;not null" json:"username"`
|
||||
Password string `gorm:"column:password" json:"-"`
|
||||
Nom string `gorm:"column:nom" json:"nom"`
|
||||
Prenom string `gorm:"column:prenom" json:"prenom"`
|
||||
Telephone string `gorm:"column:telephone;not null" json:"telephone"`
|
||||
Command int `gorm:"column:command;default:0" json:"command"`
|
||||
CancelCommande int `gorm:"column:cancel_commande;default:0" json:"cancel_commande"`
|
||||
Amende float64 `gorm:"column:amende;default:0" json:"amende"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count;not null;default:0" json:"cancellations_count"`
|
||||
LastPenaltyReason string `gorm:"column:last_penalty_reason" json:"last_penalty_reason"`
|
||||
MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"`
|
||||
ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"`
|
||||
PointsExtra map[string]int `gorm:"-" json:"points_extra"`
|
||||
PointsRedeemed map[string]int `gorm:"-" json:"points_redeemed"`
|
||||
Parrain string `gorm:"column:parrain" json:"parrain"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
TwoFAEnabled bool `gorm:"column:two_fa_enabled;default:false" json:"two_fa_enabled"`
|
||||
}
|
||||
|
||||
func (Client) TableName() string { return "clients" }
|
||||
@@ -1,56 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Command struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ClientOrderID int `gorm:"column:client_order_id" json:"client_order_id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
Username string `gorm:"column:username" json:"username"`
|
||||
Status string `gorm:"column:status" json:"status"`
|
||||
Total float64 `gorm:"column:total_prix" json:"total"`
|
||||
DeliveryAddress string `gorm:"column:adresse" json:"delivery_address"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign" json:"livreur_assign,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Command) TableName() string { return "commandes" }
|
||||
|
||||
// CommandItem représente un produit dans une commande
|
||||
type CommandItem struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CommandID int `gorm:"column:command_id" json:"command_id"`
|
||||
Produit string `gorm:"column:produit" json:"produit"`
|
||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||
Quantity float64 `gorm:"column:quantite" json:"quantity"`
|
||||
Price float64 `gorm:"column:prix" json:"price"`
|
||||
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
type CommandLog struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CommandID int `gorm:"column:command_id" json:"command_id"`
|
||||
Status string `gorm:"column:status" json:"status"`
|
||||
Message string `gorm:"column:message" json:"message"`
|
||||
Author string `gorm:"column:author" json:"author"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (CommandLog) TableName() string { return "command_logs" }
|
||||
|
||||
type CommandPriority struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Status string `json:"status"`
|
||||
Address string `json:"address"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
WaitingSeconds int `json:"waiting_seconds"`
|
||||
WaitingMinutes int `json:"waiting_minutes"`
|
||||
PriorityScore float64 `json:"priority_score"`
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package models
|
||||
|
||||
type Contact struct {
|
||||
ID int `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user