Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97381b8b68 | ||
|
|
8ebb6d2370 | ||
|
|
eb8ba01159 | ||
|
|
1672dc1e20 |
@@ -11,8 +11,32 @@ on:
|
||||
- "backend/**/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
lint:
|
||||
name: Static Analysis (golangci-lint)
|
||||
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: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
continue-on-error: true
|
||||
with:
|
||||
version: latest
|
||||
working-directory: backend/gestion
|
||||
args: --timeout=5m
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -26,21 +50,6 @@ jobs:
|
||||
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 ./...
|
||||
@@ -52,33 +61,54 @@ jobs:
|
||||
path: backend/gestion/gestion
|
||||
retention-days: 7
|
||||
|
||||
docker:
|
||||
name: Docker Build & Push
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
file: docker/backend/Dockerfile
|
||||
target: runtime
|
||||
push: true
|
||||
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'latest' || 'pre-prod' }}
|
||||
tags: xor1234/backend-mln:latest
|
||||
|
||||
- name: Build & push WAF
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker-prod/backend/Dockerfile
|
||||
file: docker/backend/Dockerfile
|
||||
target: waf
|
||||
push: true
|
||||
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'waf' || 'waf-pre-prod' }}
|
||||
tags: xor1234/backend-mln:waf
|
||||
|
||||
deploy:
|
||||
name: SSH Deploy
|
||||
needs: docker
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: SSH deploy
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY }}
|
||||
script: |
|
||||
docker compose -f ${{ secrets.COMPOSE_PATH }} pull backend waf
|
||||
docker compose -f ${{ secrets.COMPOSE_PATH }} up -d --no-deps backend waf
|
||||
|
||||
@@ -11,8 +11,9 @@ on:
|
||||
- "frontend-admin/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -23,29 +24,6 @@ jobs:
|
||||
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
|
||||
@@ -54,28 +32,29 @@ jobs:
|
||||
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
|
||||
build-apk:
|
||||
needs: typecheck
|
||||
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 Expo & EAS CLI
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend-admin
|
||||
run: npm ci
|
||||
|
||||
- name: Inject EAS project ID
|
||||
working-directory: frontend-admin
|
||||
@@ -83,79 +62,21 @@ jobs:
|
||||
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)
|
||||
- name: Build APK
|
||||
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
|
||||
run: eas build --platform android ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && '--profile production' || '--profile preview' }} --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
|
||||
- name: Download APK
|
||||
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
|
||||
APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl')
|
||||
curl -L -o admin-panel-prod.apk "$APK_URL"
|
||||
|
||||
- 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
|
||||
npx expo config --json > dist/expoconfig.json
|
||||
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
|
||||
- name: Upload production APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: admin-panel-android-prod-apk
|
||||
path: frontend-admin/admin-panel-prod.apk
|
||||
retention-days: 14
|
||||
|
||||
@@ -11,8 +11,9 @@ on:
|
||||
- "mobile/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -23,29 +24,6 @@ jobs:
|
||||
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
|
||||
@@ -54,28 +32,29 @@ jobs:
|
||||
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
|
||||
build-apk:
|
||||
needs: typecheck
|
||||
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 Expo & EAS CLI
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: mobile
|
||||
run: npm ci
|
||||
|
||||
- name: Inject EAS project ID
|
||||
working-directory: mobile
|
||||
@@ -83,79 +62,25 @@ jobs:
|
||||
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.
|
||||
- name: Debug app.json
|
||||
working-directory: mobile
|
||||
run: |
|
||||
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
|
||||
cp certs/certificate-preprod.pem certs/certificate.pem
|
||||
fi
|
||||
run: cat app.json
|
||||
|
||||
- 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)
|
||||
- name: Build APK
|
||||
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
|
||||
run: eas build --platform android ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && '--profile production' || '--profile preview' }} --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
|
||||
- name: Download production APK
|
||||
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
|
||||
APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl')
|
||||
curl -L -o client-panel-prod.apk "$APK_URL"
|
||||
|
||||
- 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
|
||||
npx expo config --json > dist/expoconfig.json
|
||||
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
|
||||
- name: Upload production APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: client-panel-android-prod-apk
|
||||
path: mobile/client-panel-prod.apk
|
||||
retention-days: 14
|
||||
|
||||
@@ -5,16 +5,18 @@ on:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-prep/**"
|
||||
- "docker-pre-prod/frontend/**"
|
||||
- "docker/frontend/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-prep/**"
|
||||
- "docker-pre-prod/frontend/**"
|
||||
- "docker/frontend/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
lint-typecheck:
|
||||
name: Lint & Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -29,11 +31,32 @@ jobs:
|
||||
working-directory: frontend-prep
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck & lint
|
||||
- name: Typecheck
|
||||
working-directory: frontend-prep
|
||||
run: |
|
||||
npx tsc -b --noEmit
|
||||
npm run lint
|
||||
run: npx tsc -b --noEmit
|
||||
|
||||
- name: Lint
|
||||
working-directory: frontend-prep
|
||||
run: npm run lint
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: lint-typecheck
|
||||
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: Build
|
||||
working-directory: frontend-prep
|
||||
@@ -41,24 +64,48 @@ jobs:
|
||||
VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }}
|
||||
run: npm run build
|
||||
|
||||
docker:
|
||||
name: Docker Build & Push
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-prod')) ||
|
||||
(github.event_name == 'pull_request' && (github.base_ref == 'main' || github.base_ref == 'pre-prod'))
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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
|
||||
file: docker/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 }}
|
||||
|
||||
deploy:
|
||||
name: Deploy to server
|
||||
needs: docker
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: SSH deploy
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY }}
|
||||
script: |
|
||||
docker compose -f ${{ secrets.COMPOSE_PATH }} pull frontend
|
||||
docker compose -f ${{ secrets.COMPOSE_PATH }} up -d --no-deps frontend
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,35 +3,19 @@ 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) {
|
||||
if result.Error != nil {
|
||||
if isNotFound(result.Error) {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
addressByUser.DeliveryAddress = correction.CorrectAddress
|
||||
return fmt.Errorf("Adresse invalide %s", correction.CorrectAddress)
|
||||
}
|
||||
|
||||
func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
|
||||
|
||||
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 {
|
||||
if err := d.GDB.Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
@@ -65,7 +65,7 @@ func (d *Database) ActivateAlert(id int) error {
|
||||
|
||||
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 {
|
||||
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
@@ -73,7 +73,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
|
||||
|
||||
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 {
|
||||
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
|
||||
+292
-189
@@ -3,29 +3,137 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
// AddProductInBasket ajoute un produit au panier de l'utilisateur
|
||||
func (d *Database) AddProductInBasket(username, nameProduct string, quantity float64, category string) (*models.Panier, error) {
|
||||
var productResult struct {
|
||||
ID int `gorm:"column:id"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT id FROM products WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?)`,
|
||||
nameProduct, category).Scan(&productResult).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la recherche du produit: %w", err)
|
||||
}
|
||||
if productResult.ID == 0 {
|
||||
return nil, fmt.Errorf("produit '%s' non trouvé dans la catégorie '%s'", nameProduct, category)
|
||||
}
|
||||
productID := productResult.ID
|
||||
|
||||
price, err := d.GetProductPrice(nameProduct, category, quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération prix: %w", err)
|
||||
}
|
||||
|
||||
var existing struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
d.GDB.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ?`,
|
||||
username, productID).Scan(&existing)
|
||||
|
||||
var basket models.Panier
|
||||
if existing.ID != 0 {
|
||||
newQuantity := existing.Quantity + quantity
|
||||
newPrice := existing.Price + price
|
||||
err = d.GDB.Raw(`
|
||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? RETURNING id, username, product_id, quantity, price, created_at`,
|
||||
newQuantity, newPrice, existing.ID).Scan(&basket).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la mise à jour du panier: %w", err)
|
||||
}
|
||||
} else {
|
||||
err = d.GDB.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, created_at`,
|
||||
username, productID, quantity, price).Scan(&basket).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de l'ajout au panier: %w", err)
|
||||
}
|
||||
}
|
||||
return &basket, nil
|
||||
}
|
||||
|
||||
// GetProductPriceByID récupère le prix d'un produit par son ID et quantité
|
||||
func (d *Database) GetProductPriceByID(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
|
||||
WHERE product_id = ? AND quantity = ROUND(?::NUMERIC, 3)
|
||||
LIMIT 1`, productID, quantity).Scan(&result).Error
|
||||
if err == nil && result.Price > 0 {
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
err = d.GDB.Raw(`
|
||||
SELECT price FROM product_prices
|
||||
WHERE product_id = ? AND quantity <= ROUND(?::NUMERIC, 3)
|
||||
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 0, fmt.Errorf("aucun prix trouvé pour product_id=%d qty=%.3f", productID, quantity)
|
||||
}
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
// GetProductStockByID récupère le stock d'un produit par son ID
|
||||
func (d *Database) GetProductStockByID(productID int) (float64, error) {
|
||||
var result struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, productID).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit %d non trouvé: %w", productID, err)
|
||||
}
|
||||
return result.Stock, nil
|
||||
}
|
||||
|
||||
// AddProductInBasketByID ajoute un produit au panier en utilisant son ID directement
|
||||
func (d *Database) AddProductInBasketByID(username string, productID int, quantity float64) (*models.Panier, error) {
|
||||
price, err := d.GetProductPriceByID(productID, quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération prix: %w", err)
|
||||
}
|
||||
|
||||
var existing struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
d.GDB.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ?`,
|
||||
username, productID).Scan(&existing)
|
||||
|
||||
var basket models.Panier
|
||||
if existing.ID != 0 {
|
||||
newQuantity := existing.Quantity + quantity
|
||||
newPrice := existing.Price + price
|
||||
err = d.GDB.Raw(`
|
||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? RETURNING id, username, product_id, quantity, price, created_at`,
|
||||
newQuantity, newPrice, existing.ID).Scan(&basket).Error
|
||||
} else {
|
||||
err = d.GDB.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, created_at`,
|
||||
username, productID, quantity, price).Scan(&basket).Error
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur panier: %w", err)
|
||||
}
|
||||
return &basket, nil
|
||||
}
|
||||
|
||||
// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée (legacy)
|
||||
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
@@ -45,11 +153,37 @@ func (d *Database) GetProductPrice(name, category string, quantity float64) (flo
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductStock(name, category string) (float64, error) {
|
||||
var result struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT stock FROM products WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?)`,
|
||||
name, category).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit non trouvé: %w", err)
|
||||
}
|
||||
return result.Stock, nil
|
||||
}
|
||||
|
||||
func (d *Database) DecrementProductStock(name, category string, quantity float64) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE products SET stock = stock - ?
|
||||
WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?) AND stock >= ?`,
|
||||
quantity, name, category, quantity)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour stock: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("stock insuffisant pour le produit")
|
||||
}
|
||||
return 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,
|
||||
SELECT b.id, b.username, b.product_id, b.quantity, b.price, 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
|
||||
@@ -61,151 +195,107 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, 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 productInfo struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
Category string `gorm:"column:category"`
|
||||
}
|
||||
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Une promotion active pour ce produit/quantité/catégorie s'applique
|
||||
// automatiquement au prix facturé — indépendamment des points de
|
||||
// fidélité (contrairement aux récompenses par palier). Le montant
|
||||
// économisé est conservé (promoDiscount) pour les statistiques
|
||||
// admin, indépendamment de la config de promo courante au moment où
|
||||
// ces stats seront consultées.
|
||||
var promoDiscount float64
|
||||
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
|
||||
promoDiscount = priceResult.Price - discounted
|
||||
priceResult.Price = discounted
|
||||
}
|
||||
|
||||
// Offre "achetez X, Y offert" : le client reçoit une quantité
|
||||
// supplémentaire du même produit, gratuite, sans changer le prix déjà
|
||||
// calculé sur la quantité demandée — la quantité livrée/décomptée du
|
||||
// stock est donc supérieure à la quantité facturée.
|
||||
freeQuantity := d.ResolveFreeGiftQuantity(productID, productInfo.Category, quantity)
|
||||
deliveredQuantity := quantity + freeQuantity
|
||||
|
||||
if productInfo.Stock < deliveredQuantity {
|
||||
return fmt.Errorf("stock insuffisant")
|
||||
}
|
||||
|
||||
var existing struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
}
|
||||
// Chercher uniquement un item normal (non-récompense) pour ce produit
|
||||
tx.Raw(`SELECT id, quantity, price, promo_discount 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 = ?, promo_discount = ?, created_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
||||
existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
|
||||
existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error
|
||||
}
|
||||
return tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at)
|
||||
VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
||||
username, productID, deliveredQuantity, priceResult.Price, promoDiscount).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)
|
||||
// DecrementProductStockByID décrémente le stock d'un produit par son ID
|
||||
func (d *Database) DecrementProductStockByID(productID int, quantity float64) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE products SET stock = stock - ?
|
||||
WHERE id = ? AND stock >= ?`, quantity, productID, quantity)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error)
|
||||
return fmt.Errorf("erreur lors de la mise à jour du stock: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d", productID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProductFromBasket supprime un produit spécifique du panier et restitue le stock.
|
||||
func (d *Database) DeleteProductFromBasket(basketID int) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var item struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
}
|
||||
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE id = ?`, basketID).Scan(&item).Error; err != nil {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
if item.ProductID == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`,
|
||||
item.Quantity, item.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur restitution stock: %w", err)
|
||||
}
|
||||
|
||||
result := tx.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 et restitue les stocks.
|
||||
func (d *Database) ClearBasket(username string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + b.quantity
|
||||
FROM baskets b
|
||||
WHERE b.username = ? AND b.product_id = p.id`, username).Error; err != nil {
|
||||
return fmt.Errorf("erreur restitution stock: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors du vidage du panier: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ClearBasketOnCheckout vide le panier après commande validée SANS restituer le stock.
|
||||
func (d *Database) ClearBasketOnCheckout(username string) error {
|
||||
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||
}
|
||||
|
||||
// GetBasketTotal calcule le montant total du panier d'un utilisateur
|
||||
func (d *Database) GetBasketTotal(username string) (float64, error) {
|
||||
var result struct {
|
||||
Total float64 `gorm:"column:total"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COALESCE(SUM(price), 0) as total FROM baskets WHERE username = ?`,
|
||||
username).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lors du calcul du total: %w", err)
|
||||
}
|
||||
return result.Total, nil
|
||||
}
|
||||
|
||||
// GetBasketItemCount compte le nombre d'items dans le panier
|
||||
func (d *Database) GetBasketItemCount(username string) (int, error) {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COUNT(*) as count FROM baskets WHERE username = ?`,
|
||||
username).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lors du comptage des items: %w", err)
|
||||
}
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
// UpdateBasketItemQuantity met à jour la quantité d'un item du panier
|
||||
func (d *Database) UpdateBasketItemQuantity(basketID int, quantity float64) error {
|
||||
if quantity <= 0 {
|
||||
return fmt.Errorf("la quantité doit être supérieure à 0")
|
||||
}
|
||||
result := d.GDB.Exec(`UPDATE baskets SET quantity = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
quantity, basketID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de la quantité: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
@@ -213,10 +303,53 @@ func (d *Database) DeleteProductFromBasket(basketID int) error {
|
||||
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
|
||||
// ExtendBasketReservations prolonge les réservations
|
||||
func (d *Database) ExtendBasketReservations(username string) error {
|
||||
var items []struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ?`, username).Scan(&items).Error; err != nil {
|
||||
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
var stockResult struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, item.ProductID).Scan(&stockResult).Error; err != nil {
|
||||
return fmt.Errorf("produit %d non trouvé: %w", item.ProductID, err)
|
||||
}
|
||||
if stockResult.Stock < item.Quantity {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d (demandé: %g, disponible: %g)",
|
||||
item.ProductID, item.Quantity, stockResult.Stock)
|
||||
}
|
||||
}
|
||||
|
||||
newReservation := time.Now().Add(15 * time.Minute)
|
||||
if err := d.GDB.Exec(`UPDATE baskets SET reserved_until = ? WHERE username = ?`,
|
||||
newReservation, username).Error; err != nil {
|
||||
return fmt.Errorf("erreur prolongation: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Réservations prolongées pour %s jusqu'à %s",
|
||||
username, newReservation.Format("15:04:05"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckBasketReservations vérifie si les réservations sont expirées
|
||||
func (d *Database) CheckBasketReservations(username string) (bool, error) {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT COUNT(*) as count FROM baskets
|
||||
WHERE username = ? AND (reserved_until IS NULL OR reserved_until < CURRENT_TIMESTAMP)`,
|
||||
username).Scan(&result).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.Count > 0, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
||||
@@ -231,39 +364,9 @@ func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
||||
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 = ?`,
|
||||
if err := d.GDB.Raw(`SELECT product_id, quantity::float8 as quantity, price::float8 as price FROM baskets WHERE username = ?`,
|
||||
username).Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// ============================================
|
||||
// db/cancel_commands_db.go
|
||||
// FONCTIONS DB ATOMIQUES POUR L'ANNULATION
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
@@ -78,17 +83,13 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
|
||||
|
||||
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)
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur remboursement stock: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||
}
|
||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
@@ -150,18 +151,20 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée)
|
||||
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 {
|
||||
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result()
|
||||
if err != nil {
|
||||
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"])
|
||||
_, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes)
|
||||
if err != nil || etaMinutes <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -176,6 +179,8 @@ func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
|
||||
}
|
||||
|
||||
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
|
||||
log.Printf("🔒 [DeleteAtomic] START - cmd=%d, by=%s (%s)", commandID, deletedBy, role)
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
@@ -183,8 +188,8 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
|
||||
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
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -194,31 +199,19 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
|
||||
|
||||
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)
|
||||
}
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
||||
} else {
|
||||
log.Printf("⏭️ [DeleteAtomic] Stock NON restitué - statut=%s", cmdResult.Status)
|
||||
log.Printf("✅ [DeleteAtomic] Stock remboursé")
|
||||
}
|
||||
|
||||
// ✅ Log suppression
|
||||
tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
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)
|
||||
@@ -323,90 +316,3 @@ func (d *Database) AddClientPenalty(username string, points int) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ type Category struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -31,7 +30,7 @@ func ValidateCategoryColor(color string) error {
|
||||
|
||||
func (d *Database) GetAllCategories() ([]Category, error) {
|
||||
var categories []Category
|
||||
if err := d.GDB.Order("position ASC, name ASC").Find(&categories).Error; err != nil {
|
||||
if err := d.GDB.Order("name ASC").Find(&categories).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if categories == nil {
|
||||
@@ -44,9 +43,7 @@ func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Categ
|
||||
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}
|
||||
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon}
|
||||
if err := d.GDB.Create(&c).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -85,18 +82,6 @@ func (d *Database) DeleteCategory(id int) error {
|
||||
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
|
||||
|
||||
+116
-205
@@ -35,16 +35,16 @@ func (d *Database) CreateClient(client *models.Client) error {
|
||||
// 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"`
|
||||
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,
|
||||
@@ -190,6 +190,44 @@ func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientStats récupère les statistiques d'un client
|
||||
func (d *Database) GetClientStats(clientID int) (map[string]interface{}, error) {
|
||||
client, err := d.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var statsResult struct {
|
||||
Total int `gorm:"column:total"`
|
||||
Pending int `gorm:"column:pending"`
|
||||
Completed int `gorm:"column:completed"`
|
||||
}
|
||||
if err := d.GDB.Raw(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COALESCE(SUM(CASE WHEN status = 'pending' OR status = 'livre' THEN 1 ELSE 0 END), 0) as pending,
|
||||
COALESCE(SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END), 0) as completed
|
||||
FROM commandes WHERE username = ?`, client.Username).Scan(&statsResult).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur calcul stats: %v", err)
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"id": clientID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"total_commands": statsResult.Total,
|
||||
"pending_commands": statsResult.Pending,
|
||||
"completed_commands": statsResult.Completed,
|
||||
"points_extra": client.PointsExtra,
|
||||
"amende": client.Amende,
|
||||
"member_since": client.CreatedAt,
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientAmende(username string) (float64, error) {
|
||||
var result struct {
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
@@ -204,6 +242,37 @@ func (d *Database) GetClientAmende(username string) (float64, error) {
|
||||
return result.Amende, nil
|
||||
}
|
||||
|
||||
func (d *Database) PayClientPenalties(username string, amountPaid float64) error {
|
||||
log.Printf("💳 [PayClientPenalties] Paiement de %.2f points pour %s", amountPaid, username)
|
||||
|
||||
currentAmount, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if currentAmount <= 0 {
|
||||
return fmt.Errorf("aucune pénalité à payer")
|
||||
}
|
||||
|
||||
if amountPaid < currentAmount {
|
||||
return fmt.Errorf("montant insuffisant: %.2f payé, %.2f requis", amountPaid, currentAmount)
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", 0.0)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [PayClientPenalties] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur paiement 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
|
||||
}
|
||||
|
||||
// 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"))
|
||||
@@ -292,28 +361,6 @@ func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error
|
||||
}
|
||||
|
||||
// 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"`
|
||||
@@ -366,7 +413,7 @@ 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) {
|
||||
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface{}, error) {
|
||||
amende, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -381,13 +428,13 @@ func (d *Database) GetClientPenaltiesInfo(username string) (map[string]any, erro
|
||||
cancellationHistory, err := d.GetClientCancellationHistory(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
|
||||
cancellationHistory = map[string]any{
|
||||
cancellationHistory = map[string]interface{}{
|
||||
"cancellations_count": cancellationsCount,
|
||||
"next_penalty": 20,
|
||||
}
|
||||
}
|
||||
|
||||
info := map[string]any{
|
||||
info := map[string]interface{}{
|
||||
"username": username,
|
||||
"total_penalty": amende,
|
||||
"cancellations_count": cancellationsCount,
|
||||
@@ -398,6 +445,21 @@ func (d *Database) GetClientPenaltiesInfo(username string) (map[string]any, erro
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CheckClientCanOrder vérifie si un client peut passer commande (pas de pénalités impayées)
|
||||
func (d *Database) CheckClientCanOrder(username string) (bool, float64, error) {
|
||||
amende, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
if amende > 0 {
|
||||
log.Printf("⚠️ [CheckClientCanOrder] Client %s bloqué: %.2f points de pénalités", username, amende)
|
||||
return false, amende, fmt.Errorf("pénalités impayées: %.2f points", amende)
|
||||
}
|
||||
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
// ResetClientPoint réinitialise les points d'un client.
|
||||
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
|
||||
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra
|
||||
@@ -433,13 +495,17 @@ func (d *Database) ResetClientPoint(username string, poolIdx int, extraPoolKey s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientPenalties(username string, _ bool) error {
|
||||
log.Printf("🔄 [ResetClientPenalties] Reset amende + cancellations_count pour %s", username)
|
||||
func (d *Database) ResetClientPenalties(username string, resetCancellationsCount bool) error {
|
||||
log.Printf("🔄 [ResetClientPenalties] Reset pour %s (reset_count=%v)", username, resetCancellationsCount)
|
||||
|
||||
result := d.GDB.Exec(
|
||||
`UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`,
|
||||
username,
|
||||
)
|
||||
var query string
|
||||
if resetCancellationsCount {
|
||||
query = `UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`
|
||||
} else {
|
||||
query = `UPDATE clients SET amende = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(query, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur reset pénalités: %w", result.Error)
|
||||
@@ -454,12 +520,12 @@ func (d *Database) ResetClientPenalties(username string, _ bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
|
||||
func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, 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"`
|
||||
Username string `gorm:"column:username"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||
UpdatedAt interface{} `gorm:"column:updated_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
|
||||
@@ -471,9 +537,9 @@ func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
|
||||
return nil, fmt.Errorf("erreur récupération clients: %w", err)
|
||||
}
|
||||
|
||||
clients := make([]map[string]any, 0, len(rows))
|
||||
clients := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
clients = append(clients, map[string]any{
|
||||
clients = append(clients, map[string]interface{}{
|
||||
"username": row.Username,
|
||||
"total_penalty": row.Amende,
|
||||
"cancellations_count": row.CancellationsCount,
|
||||
@@ -486,7 +552,7 @@ func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesStats() (map[string]any, error) {
|
||||
func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) {
|
||||
var result struct {
|
||||
ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
|
||||
TotalPenalties float64 `gorm:"column:total_penalties"`
|
||||
@@ -508,7 +574,7 @@ func (d *Database) GetClientPenaltiesStats() (map[string]any, error) {
|
||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]any{
|
||||
stats := map[string]interface{}{
|
||||
"clients_with_penalties": result.ClientsWithPenalties,
|
||||
"total_penalties": result.TotalPenalties,
|
||||
"average_penalty": result.AvgPenalty,
|
||||
@@ -631,51 +697,6 @@ func (d *Database) CalculateAndAddPointsForCommandTx(tx *gorm.DB, commandID int,
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -713,113 +734,3 @@ func (d *Database) CanUserAccessCommand(
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,41 +3,10 @@ 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"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
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
|
||||
// ============================================
|
||||
@@ -110,11 +79,13 @@ func validateItemStatus(status string) error {
|
||||
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
|
||||
if !slices.Contains(validStatuses, status) {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
for _, valid := range validStatuses {
|
||||
if status == valid {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -127,8 +98,6 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
||||
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)
|
||||
@@ -146,11 +115,8 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
||||
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 := validatePrix(prix); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateUsername(clientUsername); err != nil {
|
||||
@@ -196,12 +162,10 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
||||
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)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
commandID, produit, productID, quantite, prix,
|
||||
isReward, rewardPoolKey,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
|
||||
).Error
|
||||
if err != nil {
|
||||
@@ -217,7 +181,7 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
||||
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL
|
||||
// ============================================
|
||||
|
||||
func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, error) {
|
||||
log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
@@ -227,31 +191,28 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
@@ -262,8 +223,6 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
ci.product_id,
|
||||
ci.quantite,
|
||||
ci.prix,
|
||||
ci.is_reward,
|
||||
ci.reward_pool_key,
|
||||
ci.client_username,
|
||||
ci.client_nom,
|
||||
ci.client_prenom,
|
||||
@@ -278,8 +237,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
c.referral_used,
|
||||
c.livreur_assign,
|
||||
c.created_at as command_created_at,
|
||||
COALESCE(p.category, '') as category,
|
||||
COALESCE(p.unit, '') as unit,
|
||||
p.category,
|
||||
c.client_order_id as client_order_number
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
@@ -291,27 +249,25 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
return nil, fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
items := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
productIDValue := 0
|
||||
if row.ProductID != nil {
|
||||
productIDValue = int(*row.ProductID)
|
||||
}
|
||||
|
||||
var commandCreatedAt any
|
||||
var commandCreatedAt interface{}
|
||||
if row.CommandCreatedAt != nil {
|
||||
commandCreatedAt = *row.CommandCreatedAt
|
||||
}
|
||||
|
||||
item := map[string]any{
|
||||
item := map[string]interface{}{
|
||||
"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,
|
||||
@@ -321,14 +277,13 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
"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_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)
|
||||
@@ -338,92 +293,6 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
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 {
|
||||
@@ -432,12 +301,104 @@ func ptrStr(s *string) string {
|
||||
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) GetCommandItemsByUsername(username string) ([]map[string]interface{}, error) {
|
||||
if err := validateUsername(username); err != nil {
|
||||
log.Printf("❌ [GetCommandItemsByUsername] %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"`
|
||||
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"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
ci.id,
|
||||
ci.command_id,
|
||||
ci.produit,
|
||||
ci.product_id,
|
||||
ci.quantite,
|
||||
ci.prix,
|
||||
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.livreur_assign,
|
||||
c.created_at as command_created_at
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
WHERE ci.client_username = ?
|
||||
ORDER BY ci.command_id DESC, ci.id ASC`, username).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]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
productIDValue := 0
|
||||
if row.ProductID != nil {
|
||||
productIDValue = int(*row.ProductID)
|
||||
}
|
||||
|
||||
var commandCreatedAt interface{}
|
||||
if row.CommandCreatedAt != nil {
|
||||
commandCreatedAt = *row.CommandCreatedAt
|
||||
}
|
||||
|
||||
item := map[string]interface{}{
|
||||
"id": row.ID,
|
||||
"command_id": row.CommandID,
|
||||
"produit": row.Produit,
|
||||
"product_id": productIDValue,
|
||||
"quantite": row.Quantite,
|
||||
"prix": row.Prix,
|
||||
"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,
|
||||
"livreur_assign": ptrStr(row.LivreurAssign),
|
||||
"command_created_at": commandCreatedAt,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
log.Printf("✅ %d items récupérés pour l'utilisateur %s", len(items), username)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
||||
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
|
||||
|
||||
@@ -448,55 +409,33 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
||||
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)
|
||||
}
|
||||
// Récupérer le prix et la quantité avant suppression pour mettre à jour le total
|
||||
var result struct {
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT prix, quantite 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Supprimer l'item
|
||||
if err := d.GDB.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(`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)
|
||||
}
|
||||
// Recalculer le total de la commande
|
||||
if err := d.GDB.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)
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
|
||||
|
||||
@@ -7,6 +7,7 @@ package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
)
|
||||
@@ -43,13 +44,95 @@ func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[str
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetOldestPendingCommand récupère la commande pending la plus ancienne
|
||||
func (d *Database) GetOldestPendingCommand() (map[string]any, error) {
|
||||
var commands []map[string]any
|
||||
err := d.GDB.Raw(`
|
||||
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 c.status = 'pending'
|
||||
ORDER BY c.created_at ASC
|
||||
LIMIT 1`).Scan(&commands).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commande la plus ancienne: %w", err)
|
||||
}
|
||||
if len(commands) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return commands[0], nil
|
||||
}
|
||||
|
||||
// GetPendingCommandsWithPriority récupère les commandes pending avec calcul de priorité
|
||||
func (d *Database) GetPendingCommandsWithPriority() ([]*models.CommandPriority, error) {
|
||||
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"`
|
||||
CreatedAt string `gorm:"column:created_at"`
|
||||
UpdatedAt string `gorm:"column:updated_at"`
|
||||
WaitingSeconds float64 `gorm:"column:waiting_seconds"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.created_at, c.updated_at,
|
||||
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - c.created_at)) as waiting_seconds
|
||||
FROM commandes c
|
||||
WHERE c.status = 'pending'
|
||||
ORDER BY c.created_at ASC`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commandes avec priorité: %w", err)
|
||||
}
|
||||
|
||||
commands := make([]*models.CommandPriority, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
cmd := &models.CommandPriority{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Status: row.Status,
|
||||
Address: row.Adresse,
|
||||
TotalPrice: row.TotalPrix,
|
||||
WaitingSeconds: int(row.WaitingSeconds),
|
||||
WaitingMinutes: int(row.WaitingSeconds / 60),
|
||||
}
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetCommandWaitingTime récupère le temps d'attente d'une commande
|
||||
func (d *Database) GetCommandWaitingTime(commandID int) (int, error) {
|
||||
var result struct {
|
||||
WaitingSeconds int `gorm:"column:waiting_seconds"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))::INTEGER as waiting_seconds
|
||||
FROM commandes WHERE id = ?`, commandID).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération temps d'attente: %w", err)
|
||||
}
|
||||
if result.WaitingSeconds == 0 {
|
||||
// Vérifie si la commande existe vraiment
|
||||
var exists bool
|
||||
d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM commandes WHERE id = ?)`, commandID).Scan(&exists)
|
||||
if !exists {
|
||||
return 0, fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
}
|
||||
return result.WaitingSeconds, 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"`
|
||||
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(`
|
||||
|
||||
+183
-238
@@ -1,8 +1,6 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
@@ -13,9 +11,6 @@ import (
|
||||
"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 {
|
||||
@@ -50,12 +45,21 @@ func validateAddress(address string) error {
|
||||
}
|
||||
|
||||
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"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
|
||||
func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) {
|
||||
var items []basketItem
|
||||
if err := d.GDB.Table("baskets").Select("product_id, quantity, price").Where("username = ?", username).Scan(&items).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
total := 0.0
|
||||
for _, item := range items {
|
||||
total += item.Price
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// validateCommandStatus vérifie si le statut est valide
|
||||
@@ -78,6 +82,72 @@ func validateCommandStatus(status string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCommand(username string) (*models.Command, error) {
|
||||
adresse := "Adresse non spécifiée"
|
||||
var clientCheck models.Client
|
||||
if err := d.GDB.Select("username").Where("username = ?", username).First(&clientCheck).Error; err == nil && clientCheck.Username != "" {
|
||||
adresse = clientCheck.Username
|
||||
}
|
||||
|
||||
basketItems, totalPrix, err := d.fetchBasketItems(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(basketItems) == 0 {
|
||||
return nil, fmt.Errorf("le panier est vide")
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
err = d.GDB.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", adresse, totalPrix, username).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la création de la commande: %w", err)
|
||||
}
|
||||
|
||||
commandID := cmdResult.ID
|
||||
|
||||
for _, item := range basketItems {
|
||||
productName, err := d.GetProductNameByID(item.ProductID)
|
||||
if err != nil {
|
||||
productName = "Produit inconnu"
|
||||
}
|
||||
|
||||
cmdItem := models.CommandItem{
|
||||
CommandID: commandID,
|
||||
Produit: productName,
|
||||
ProductID: item.ProductID,
|
||||
Quantity: item.Quantity,
|
||||
Price: item.Price,
|
||||
}
|
||||
if err := d.GDB.Create(&cmdItem).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de l'insertion des items: %w", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if err := d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du vidage du panier: %w", err)
|
||||
}
|
||||
|
||||
command := &models.Command{
|
||||
ID: commandID,
|
||||
ClientOrderID: cmdResult.ClientOrderID,
|
||||
Status: "pending",
|
||||
Total: totalPrix,
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
@@ -101,123 +171,90 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
||||
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, promo_discount 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,
|
||||
PromoDiscount: item.PromoDiscount,
|
||||
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
|
||||
})
|
||||
basketItems, totalPrix, err := d.fetchBasketItems(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur création commande: %v", err)
|
||||
log.Printf("❌ Erreur query basket: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(basketItems) == 0 {
|
||||
return nil, fmt.Errorf("le panier est vide")
|
||||
}
|
||||
|
||||
for _, item := range basketItems {
|
||||
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
|
||||
return nil, fmt.Errorf("données panier invalides")
|
||||
}
|
||||
}
|
||||
|
||||
if totalPrix <= 0 || totalPrix > 100000 {
|
||||
return nil, 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"`
|
||||
}
|
||||
err = d.GDB.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
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur création commande: %w", err)
|
||||
}
|
||||
|
||||
commandID := cmdResult.ID
|
||||
|
||||
for _, item := range basketItems {
|
||||
productName, err := d.GetProductNameByID(item.ProductID)
|
||||
if err != nil || productName == "" {
|
||||
productName = fmt.Sprintf("Produit #%d", item.ProductID)
|
||||
}
|
||||
|
||||
err = d.InsertCommandItemWithClientInfo(
|
||||
commandID,
|
||||
productName,
|
||||
item.ProductID,
|
||||
item.Quantity,
|
||||
item.Price,
|
||||
username,
|
||||
clientNom,
|
||||
clientPrenom,
|
||||
clientTelephone,
|
||||
deliveryAddress,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur INSERT command_items: %v", err)
|
||||
return nil, fmt.Errorf("erreur insertion items: %w", err)
|
||||
}
|
||||
|
||||
// Stock déjà déduit à l'ajout au panier — ne pas déduire une seconde fois ici.
|
||||
}
|
||||
|
||||
if err := d.GDB.Delete(&models.Panier{}, "username = ?", username).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur vidage panier: %v", err)
|
||||
}
|
||||
|
||||
sanitizedAddress := sanitizeLogMessage(deliveryAddress)
|
||||
d.AddCommandLog(command.ID, "created",
|
||||
d.AddCommandLog(commandID, "created",
|
||||
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
|
||||
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
|
||||
username)
|
||||
|
||||
command := &models.Command{
|
||||
ID: commandID,
|
||||
ClientOrderID: cmdResult.ClientOrderID,
|
||||
Username: username,
|
||||
Status: "pending",
|
||||
Total: totalPrix,
|
||||
DeliveryAddress: deliveryAddress,
|
||||
CreatedAt: cmdResult.CreatedAt,
|
||||
UpdatedAt: cmdResult.UpdatedAt,
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
@@ -312,6 +349,14 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]any, er
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandCount() (int, error) {
|
||||
var count int64
|
||||
if err := d.GDB.Model(&models.Command{}).Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération count commandes: %w", err)
|
||||
}
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
|
||||
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
|
||||
}
|
||||
@@ -331,17 +376,13 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
||||
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`).
|
||||
COALESCE(c.cancel_reason, '') AS cancel_reason`).
|
||||
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)
|
||||
@@ -362,8 +403,6 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
||||
"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 {
|
||||
@@ -381,54 +420,6 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
||||
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 {
|
||||
@@ -441,6 +432,20 @@ func (d *Database) GetClientOrderID(commandID int) int {
|
||||
return result.ClientOrderID
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandAddress(commandID int) (string, error) {
|
||||
var result struct {
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.Command{}).Select("adresse").Where("id = ?", commandID).First(&result).Error; err != nil {
|
||||
return "", fmt.Errorf("erreur lors de la récupération de l'adresse: %w", err)
|
||||
}
|
||||
if result.Adresse == "" {
|
||||
return "", fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
return result.Adresse, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
|
||||
if len(deliveryAddress) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||||
@@ -464,36 +469,6 @@ func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) e
|
||||
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 {
|
||||
@@ -657,7 +632,7 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
|
||||
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"}
|
||||
validStatuses := []string{"assigned", "en_route", "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)
|
||||
@@ -782,11 +757,6 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
|
||||
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)
|
||||
@@ -836,9 +806,6 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyApproved) {
|
||||
return 0, "", nil
|
||||
}
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
@@ -894,25 +861,13 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
|
||||
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)
|
||||
if cmd.Status != "livre" {
|
||||
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 = ?`, commandID, cmd.Status)
|
||||
WHERE id = ? AND status = 'livre'`, commandID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
@@ -970,13 +925,3 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
|
||||
|
||||
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
|
||||
}
|
||||
@@ -95,6 +95,7 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliveryPersonCommands récupère les commandes assignées à un livreur
|
||||
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
|
||||
@@ -105,10 +106,6 @@ func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status stri
|
||||
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"
|
||||
@@ -120,6 +117,22 @@ func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status stri
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (d *Database) IncrementLivreurDeliveryCount(livreurUsername string) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE users
|
||||
SET livraison = livraison + 1,
|
||||
total = total + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ? AND role = 'livreur'`, livreurUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'incrémentation des livraisons: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("livreur non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
|
||||
@@ -221,3 +221,79 @@ func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllDeliveryPersonsStats récupère les stats de tous les livreurs
|
||||
func (d *Database) GetAllDeliveryPersonsStats() ([]map[string]any, error) {
|
||||
livreurs, err := d.GetAvailableDeliveryPersons()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération livreurs: %w", err)
|
||||
}
|
||||
|
||||
var stats []map[string]any
|
||||
for _, livreur := range livreurs {
|
||||
username := livreur["username"].(string)
|
||||
|
||||
totalDeliveries, _ := d.CountDeliveriesByStatus(username, "")
|
||||
completedDeliveries, _ := d.CountDeliveriesByStatus(username, "approved")
|
||||
queueSize, _ := d.GetDeliverymanQueueSize(username)
|
||||
status, _ := d.GetDeliveryPersonStatus(username)
|
||||
|
||||
stats = append(stats, map[string]any{
|
||||
"username": username,
|
||||
"total_deliveries": totalDeliveries,
|
||||
"completed_deliveries": completedDeliveries,
|
||||
"queue_size": queueSize,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonsByStatus récupère les livreurs par statut
|
||||
func (d *Database) GetDeliveryPersonsByStatus(status string) ([]string, error) {
|
||||
livreurs, err := d.GetAvailableDeliveryPersons()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération livreurs: %w", err)
|
||||
}
|
||||
|
||||
var filteredLivreurs []string
|
||||
for _, livreur := range livreurs {
|
||||
username := livreur["username"].(string)
|
||||
currentStatus, _ := d.GetDeliveryPersonStatus(username)
|
||||
if currentStatus == status {
|
||||
filteredLivreurs = append(filteredLivreurs, username)
|
||||
}
|
||||
}
|
||||
return filteredLivreurs, nil
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsCount compte les livreurs disponibles
|
||||
func (d *Database) GetAvailableDeliveryPersonsCount() (int, error) {
|
||||
availableLivreurs, err := d.GetDeliveryPersonsByStatus("available")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(availableLivreurs), nil
|
||||
}
|
||||
|
||||
// ClearDeliveryPersonData supprime toutes les données d'un livreur (admin uniquement)
|
||||
func (d *Database) ClearDeliveryPersonData(livreurUsername string) error {
|
||||
log.Printf("🗑️ [ClearDeliveryData] Nettoyage données pour: %s", livreurUsername)
|
||||
|
||||
keys := []string{
|
||||
fmt.Sprintf("delivery:status:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:location:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:queue:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:queue:size:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:current:%s", livreurUsername),
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if err := Redis.Del(RedisCtx, key).Err(); err != nil {
|
||||
log.Printf("⚠️ [ClearDeliveryData] Erreur suppression clé %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [ClearDeliveryData] Données nettoyées pour %s", livreurUsername)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package db
|
||||
|
||||
import "gestion/models"
|
||||
|
||||
// ResolveFreeGift retourne la quantité offerte (du même produit) pour un
|
||||
// produit, sa catégorie catalogue et une quantité commandée donnés — le seuil
|
||||
// le plus élevé (BuyQuantity) atteint par la quantité commandée est retenu,
|
||||
// tous seuils confondus pour ce produit (ex: seuils 10g→+1g et 20g→+3g, une
|
||||
// commande de 25g retient +3g, pas +1g).
|
||||
func ResolveFreeGift(settings *models.AppSettings, productID int, category string, quantity float64) float64 {
|
||||
if settings == nil || !settings.FreeGiftsEnabled {
|
||||
return 0
|
||||
}
|
||||
|
||||
var bestBuy, bestFree float64
|
||||
found := false
|
||||
consider := func(tiers []models.FreeGiftTier) {
|
||||
for _, t := range tiers {
|
||||
if t.BuyQuantity <= 0 || t.FreeQuantity <= 0 || quantity < t.BuyQuantity {
|
||||
continue
|
||||
}
|
||||
if !found || t.BuyQuantity > bestBuy {
|
||||
bestBuy, bestFree = t.BuyQuantity, t.FreeQuantity
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, g := range settings.FreeGifts {
|
||||
if g.Category != category {
|
||||
continue
|
||||
}
|
||||
if g.AllProducts {
|
||||
consider(g.Tiers)
|
||||
continue
|
||||
}
|
||||
for _, pq := range g.Products {
|
||||
if pq.ProductID == productID {
|
||||
consider(pq.Tiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return 0
|
||||
}
|
||||
return bestFree
|
||||
}
|
||||
|
||||
// ResolveFreeGiftQuantity lit les settings courants et applique
|
||||
// ResolveFreeGift — wrapper pratique pour les appelants qui n'ont pas déjà
|
||||
// les settings sous la main (même style que ApplyPromotionToPrice).
|
||||
func (d *Database) ResolveFreeGiftQuantity(productID int, category string, quantity float64) float64 {
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return ResolveFreeGift(&settings, productID, category, quantity)
|
||||
}
|
||||
@@ -25,16 +25,20 @@ func wazeAppLink(lat, lon float64) string {
|
||||
return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon)
|
||||
}
|
||||
|
||||
// GenerateMapLinks génère tous les liens de cartes pour une position GPS
|
||||
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
|
||||
return MapLinks{
|
||||
WazeApp: wazeAppLink(lat, lon),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateNavigationLink génère un lien de navigation vers une destination
|
||||
// fromLat/fromLon sont ignorés : Waze part toujours de la position GPS courante
|
||||
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
|
||||
return wazeAppLink(toLat, toLon)
|
||||
}
|
||||
|
||||
// GenerateMapLinksForCommand génère les liens de navigation pour une commande
|
||||
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
|
||||
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,8 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
|
||||
@@ -35,3 +37,114 @@ func (d *Database) GetCompletedCommandsByUsername(username string) ([]map[string
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetCompletedCommandsWithItems récupère les commandes terminées avec leurs items
|
||||
func (d *Database) GetCompletedCommandsWithItems(username string) ([]map[string]any, error) {
|
||||
commands, err := d.GetCompletedCommandsByUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var enrichedCommands []map[string]any
|
||||
|
||||
for _, command := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
|
||||
if commandID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
items, err := d.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetCompletedWithItems] Erreur items pour cmd %d: %v", commandID, err)
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
enrichedCommand := make(map[string]any)
|
||||
maps.Copy(enrichedCommand, command)
|
||||
enrichedCommand["items"] = items
|
||||
enrichedCommand["items_count"] = len(items)
|
||||
|
||||
enrichedCommands = append(enrichedCommands, enrichedCommand)
|
||||
}
|
||||
|
||||
return enrichedCommands, nil
|
||||
}
|
||||
|
||||
// GetCommandsStatsByUsername récupère les statistiques des commandes d'un utilisateur
|
||||
func (d *Database) GetCommandsStatsByUsername(username string) (map[string]any, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'approved') as approved_count,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
|
||||
COUNT(*) FILTER (WHERE status = 'assigned') as assigned_count,
|
||||
COUNT(*) FILTER (WHERE status = 'en_route') as en_route_count,
|
||||
COUNT(*) FILTER (WHERE status = 'livre') as livre_count,
|
||||
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
|
||||
COUNT(*) as total_count,
|
||||
COALESCE(SUM(total_prix) FILTER (WHERE status = 'approved'), 0) as total_spent
|
||||
FROM commandes
|
||||
WHERE username = ?
|
||||
`
|
||||
|
||||
var result map[string]any
|
||||
if err := d.GDB.Raw(query, username).Scan(&result).Error; err != nil {
|
||||
log.Printf("❌ [GetCommandsStats] Erreur: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des statistiques: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCommandsByStatus récupère les commandes d'un utilisateur par statut
|
||||
func (d *Database) GetCommandsByStatus(username, status string) ([]map[string]any, error) {
|
||||
log.Printf("📋 [GetCommandsByStatus] START - username=%s, status=%s", username, status)
|
||||
|
||||
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 = ?
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, username, status).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetRecentCompletedOrders récupère les N dernières commandes terminées d'un utilisateur
|
||||
func (d *Database) GetRecentCompletedOrders(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,
|
||||
livreur_assign,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM commandes
|
||||
WHERE username = ? AND status = 'approved'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, username, limit).Scan(&commands).Error; err != nil {
|
||||
log.Printf("❌ [GetRecentCompleted] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func InitDB() *Database {
|
||||
// Configuration du pool de connexions
|
||||
db.SetMaxOpenConns(50)
|
||||
db.SetMaxIdleConns(10)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
// Tester la connexion
|
||||
if err = db.Ping(); err != nil {
|
||||
@@ -66,9 +66,7 @@ func InitDB() *Database {
|
||||
gormDB, err := gorm.Open(postgres.New(postgres.Config{
|
||||
Conn: db,
|
||||
}), &gorm.Config{
|
||||
SkipDefaultTransaction: true,
|
||||
PrepareStmt: true,
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur initialisation GORM: %v", err)
|
||||
@@ -122,38 +120,6 @@ func InitDB() *Database {
|
||||
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: baskets.promo_discount + command_items.promo_discount —
|
||||
// montant (en €) économisé par une promotion de prix sur cette ligne,
|
||||
// capturé une fois pour toutes au moment de AddToBasket (voir
|
||||
// db_basket.go) puis copié tel quel au checkout, pour permettre des
|
||||
// statistiques historiques fiables même si la config de promo change
|
||||
// ensuite (contrairement à un recalcul a posteriori sur les settings
|
||||
// courants, qui donnerait un résultat faux pour les anciennes commandes).
|
||||
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.promo_discount: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.promo_discount: %v", err)
|
||||
}
|
||||
|
||||
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
||||
if _, err = database.Exec(`
|
||||
DO $$
|
||||
@@ -181,23 +147,6 @@ func InitDB() *Database {
|
||||
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,
|
||||
@@ -287,44 +236,6 @@ func InitDB() *Database {
|
||||
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()
|
||||
|
||||
@@ -369,7 +280,6 @@ func (db *Database) createTables() error {
|
||||
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
|
||||
);`,
|
||||
@@ -431,7 +341,6 @@ func (db *Database) createTables() error {
|
||||
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
|
||||
);`,
|
||||
|
||||
@@ -555,38 +464,6 @@ func (db *Database) createTables() error {
|
||||
`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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -59,8 +59,8 @@ func validateMediaURL(url string) error {
|
||||
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/")
|
||||
if !strings.HasPrefix(url, "/uploads/") {
|
||||
return fmt.Errorf("URL doit commencer par /uploads/")
|
||||
}
|
||||
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
|
||||
for _, char := range dangerousChars {
|
||||
@@ -93,11 +93,6 @@ func (d *Database) CreateMedia(media any) error {
|
||||
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
|
||||
@@ -111,14 +106,14 @@ func (d *Database) CreateMedia(media any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.InsertMedia(m, productID, mediaURL, mediaType, mediaKey); err != nil {
|
||||
if err := d.InsertMedia(m, productID, mediaURL, mediaType); 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 {
|
||||
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType 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)
|
||||
@@ -133,9 +128,9 @@ func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType str
|
||||
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(),
|
||||
INSERT INTO media (product_id, url, type, created_at)
|
||||
VALUES (?, ?, ?, ?) RETURNING id`,
|
||||
productID, mediaURL, mediaType, time.Now(),
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
|
||||
@@ -159,7 +154,7 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
|
||||
|
||||
var media models.Media
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, product_id, url, type, key, created_at
|
||||
SELECT id, product_id, url, type, created_at
|
||||
FROM media WHERE id = ?`, mediaID).Scan(&media).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
|
||||
@@ -174,20 +169,6 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
|
||||
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)
|
||||
|
||||
@@ -198,7 +179,7 @@ func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
|
||||
|
||||
var mediaList []models.Media
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, product_id, url, type, key, created_at
|
||||
SELECT id, product_id, url, type, created_at
|
||||
FROM media WHERE product_id = ?
|
||||
ORDER BY id ASC`, productID).Scan(&mediaList).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -9,18 +9,6 @@ import (
|
||||
"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)
|
||||
|
||||
@@ -33,20 +21,12 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
|
||||
}
|
||||
|
||||
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
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && 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)
|
||||
}
|
||||
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +34,7 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyLivreur envoie une notification in-app (Redis) à un livreur
|
||||
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
|
||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||
|
||||
@@ -66,20 +47,12 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
|
||||
}
|
||||
|
||||
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
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && 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)
|
||||
}
|
||||
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +60,7 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines
|
||||
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
|
||||
var users []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
@@ -107,27 +81,25 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
|
||||
}
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
count := 0
|
||||
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
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
count := len(users)
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
for _, u := range users {
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||
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))
|
||||
capturedMsg := msg
|
||||
go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", capturedMsg))
|
||||
}
|
||||
}
|
||||
count++
|
||||
}
|
||||
log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID)
|
||||
}
|
||||
|
||||
// NotifyAllAdminCabineAlert envoie une notification Redis à tous les admins/cabines lors d'une alerte
|
||||
func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
|
||||
var users []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
@@ -148,23 +120,20 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert
|
||||
}
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
count := 0
|
||||
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
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
count := len(users)
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
for _, u := range users {
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||
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))
|
||||
capturedBody := body
|
||||
go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", capturedBody))
|
||||
}
|
||||
}
|
||||
count++
|
||||
}
|
||||
log.Printf("🚨 [ALERT_NOTIF] Notif Redis (%d users) pour alerte #%d de %s", count, alertID, livreurUsername)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
|
||||
@@ -21,44 +18,13 @@ func (d *Database) SetClientParrain(clientUsername, parrainUsername string) erro
|
||||
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
|
||||
var parrain string
|
||||
err := d.GDB.Table("clients").
|
||||
Select("parrain").
|
||||
Where("username = ?", clientUsername).
|
||||
Scan(&parrain).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parrain.String, nil
|
||||
return parrain, err
|
||||
}
|
||||
|
||||
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -66,17 +67,6 @@ func (d *Database) ActivateCryptoCommand(commandID int) 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
|
||||
@@ -87,9 +77,9 @@ func (d *Database) CancelCryptoCommand(commandID int) error {
|
||||
}
|
||||
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)
|
||||
log.Printf("[CANCEL CRYPTO] erreur restauration stock produit %d: %v", it.ProductID, err)
|
||||
}
|
||||
}
|
||||
return tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = NOW() WHERE id = ?`, commandID).Error
|
||||
return tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = NOW() WHERE id = ? AND status = 'pending_payment'`, commandID).Error
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreateProduct crée un nouveau produit avec ses prix
|
||||
@@ -54,15 +52,10 @@ func (d *Database) CreateProduct(product any) error {
|
||||
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,
|
||||
INSERT INTO products (name, category, description, stock, unit, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
|
||||
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), now, now,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
|
||||
@@ -75,21 +68,14 @@ func (d *Database) CreateProduct(product any) error {
|
||||
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)
|
||||
for i, price := range p.GetPrices() {
|
||||
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`,
|
||||
result.ID, price.Quantity, price.Price).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
|
||||
return fmt.Errorf("erreur insertion prix: %v", err)
|
||||
}
|
||||
log.Printf("✅ [DB CreateProduct] %d prix insérés", len(priceRows))
|
||||
log.Printf("✅ [DB CreateProduct] Prix[%d] inséré: quantity=%g, price=%.2f", i, price.Quantity, price.Price)
|
||||
}
|
||||
|
||||
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
|
||||
@@ -102,7 +88,7 @@ func (d *Database) GetProductByID(id int) (models.Product, error) {
|
||||
|
||||
var p models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
FROM products
|
||||
WHERE id = ?`, id).Scan(&p).Error
|
||||
if err != nil {
|
||||
@@ -124,54 +110,12 @@ func (d *Database) GetProductByID(id int) (models.Product, error) {
|
||||
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
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
FROM products
|
||||
ORDER BY id ASC`).Scan(&products).Error
|
||||
if err != nil {
|
||||
@@ -179,22 +123,23 @@ func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||
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 {
|
||||
prices, err := d.GetProductPrices(products[i].ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetAllProducts] Erreur loading prices for product %d: %v", products[i].ID, err)
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
}
|
||||
if media, ok := allMedia[products[i].ID]; ok {
|
||||
products[i].Media = media
|
||||
} else {
|
||||
products[i].Prices = prices
|
||||
log.Printf("✅ [GetAllProducts] Loaded %d prices for product %d", len(prices), products[i].ID)
|
||||
}
|
||||
|
||||
media, err := d.GetMediaByProductID(products[i].ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetAllProducts] Erreur loading media for product %d: %v", products[i].ID, err)
|
||||
products[i].Media = []models.Media{}
|
||||
} else {
|
||||
products[i].Media = media
|
||||
log.Printf("✅ [GetAllProducts] Loaded %d media for product %d", len(media), products[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +153,7 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
|
||||
|
||||
var products []models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
FROM products
|
||||
WHERE category = ?
|
||||
ORDER BY created_at DESC`, category).Scan(&products).Error
|
||||
@@ -217,16 +162,14 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, 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 {
|
||||
prices, err := d.GetProductPrices(products[i].ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetProductsByCategory] Erreur loading prices for product %d: %v", products[i].ID, err)
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
} else {
|
||||
products[i].Prices = prices
|
||||
log.Printf("✅ [GetProductsByCategory] Loaded %d prices for product %d", len(prices), products[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,73 +177,28 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
|
||||
return products, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateProduct(productID int, name, category, description, unit string, comingSoon bool, prices []models.ProductPrice) error {
|
||||
func (d *Database) UpdateProduct(productID int, name, category, description, unit string, stock float64, prices []models.ProductPrice) error {
|
||||
err := d.GDB.Exec(`
|
||||
UPDATE products
|
||||
SET name = ?, category = ?, description = ?, unit = ?, coming_soon = ?, updated_at = ?
|
||||
SET name = ?, category = ?, description = ?, stock = ?, unit = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
name, category, description, unit, comingSoon, time.Now(), productID).Error
|
||||
name, category, description, stock, unit, 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)
|
||||
for _, price := range prices {
|
||||
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`,
|
||||
productID, price.Quantity, price.Price).Error; err != nil {
|
||||
log.Printf("❌ [UpdateProduct] Erreur prix: %v", 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)
|
||||
|
||||
@@ -13,27 +13,19 @@ func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error
|
||||
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
|
||||
func (d *Database) CreateProductPrice(productID int, quantity float64, price float64) error {
|
||||
p := models.ProductPrice{ProductID: productID, Quantity: quantity, Price: price}
|
||||
if err := d.GDB.Create(&p).Error; err != nil {
|
||||
return fmt.Errorf("erreur création prix: %w", err)
|
||||
}
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AddActivePrice(priceID int) error {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).
|
||||
Where("id = ?", priceID).
|
||||
Update("active_price", true)
|
||||
|
||||
func (d *Database) UpdateProductPrice(priceID int, quantity float64, price float64) error {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).Where("id = ?", priceID).
|
||||
Updates(map[string]any{"quantity": quantity, "price": price})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour prix: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
@@ -41,13 +33,10 @@ func (d *Database) AddActivePrice(priceID int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeActivePrice(priceID int) error {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).
|
||||
Where("id = ?", priceID).
|
||||
Update("active_price", false)
|
||||
|
||||
func (d *Database) DeleteProductPrice(priceID int) error {
|
||||
result := d.GDB.Delete(&models.ProductPrice{}, priceID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
|
||||
return fmt.Errorf("erreur suppression prix: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ResolvePromotionDiscount retourne le pourcentage de réduction actif pour un
|
||||
// produit, sa catégorie catalogue et une quantité donnés, si une promotion
|
||||
// configurée dans les settings couvre exactement ce couple (produit,
|
||||
// quantité) — contrairement aux récompenses, aucun seuil de points n'entre
|
||||
// en jeu : la promotion s'applique à toute commande de cette quantité.
|
||||
func ResolvePromotionDiscount(settings *models.AppSettings, productID int, category string, quantity float64) (float64, bool) {
|
||||
if settings == nil || !settings.PromotionsEnabled {
|
||||
return 0, false
|
||||
}
|
||||
for _, promo := range settings.Promotions {
|
||||
if promo.Category != category || promo.DiscountPercent <= 0 {
|
||||
continue
|
||||
}
|
||||
if promo.AllProducts {
|
||||
if promo.Quantity == quantity {
|
||||
return promo.DiscountPercent, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, pq := range promo.Products {
|
||||
if pq.ProductID == productID && pq.Quantity == quantity {
|
||||
return promo.DiscountPercent, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ApplyPromotionToPrice applique la réduction (si une promotion couvre ce
|
||||
// produit/quantité/catégorie) au prix catalogue donné, arrondi au centime.
|
||||
func (d *Database) ApplyPromotionToPrice(productID int, category string, quantity, price float64) (float64, bool) {
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
return price, false
|
||||
}
|
||||
discount, ok := ResolvePromotionDiscount(&settings, productID, category, quantity)
|
||||
if !ok {
|
||||
return price, false
|
||||
}
|
||||
return math.Round(price*(1-discount/100)*100) / 100, true
|
||||
}
|
||||
@@ -189,7 +189,7 @@ func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string)
|
||||
}
|
||||
|
||||
// 4. Vérifier toutes les autres queues de livreurs (au cas où)
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
|
||||
@@ -58,3 +58,17 @@ func (d *Database) ResetClientReferralBalance(username string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) UseClientReferralBalance(tx *gorm.DB, username string, amount float64) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -27,6 +27,25 @@ func (d *Database) GetClientCancellationsCount(username string) (int, error) {
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
// IncrementClientCancellationsCount incrémente le compteur d'annulations
|
||||
func (d *Database) IncrementClientCancellationsCount(username string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
|
||||
"cancellations_count": gorm.Expr("COALESCE(cancellations_count, 0) + 1"),
|
||||
})
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [IncrementCancellations] Erreur: %v", result.Error)
|
||||
return fmt.Errorf("erreur incrémentation: %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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -45,16 +64,6 @@ func penaltyForCount(count int, tiers []models.PenaltyTier) int {
|
||||
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)
|
||||
@@ -62,7 +71,13 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
penalty := penaltyForCount(count, d.penaltyTiers("CalculatePenalty"))
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CalculatePenalty] Impossible de charger les settings, barème par défaut: %v", err)
|
||||
settings = DefaultSettings()
|
||||
}
|
||||
|
||||
penalty := penaltyForCount(count, settings.PenaltyTiers)
|
||||
|
||||
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
|
||||
username, count, penalty)
|
||||
@@ -70,48 +85,30 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
||||
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).
|
||||
// ApplyCancellationPenalty applique une pénalité et incrémente le compteur d'annulations
|
||||
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
|
||||
})
|
||||
penalty, err := d.CalculateCancellationPenalty(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
|
||||
|
||||
if err := d.IncrementClientCancellationsCount(username); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", float64(penalty))
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
|
||||
return 0, fmt.Errorf("erreur application pénalité: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return 0, fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
|
||||
@@ -66,25 +66,12 @@ func DefaultSettings() models.AppSettings {
|
||||
},
|
||||
},
|
||||
},
|
||||
ShopName: "Milieu-Nantais",
|
||||
ContactTelegram: "MLN44LA",
|
||||
ShopName: "Milieu-Nantais",
|
||||
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(),
|
||||
DeliverySchedule: DefaultDeliverySchedule(),
|
||||
PostalZones: []models.PostalZone{
|
||||
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
|
||||
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
|
||||
@@ -115,11 +102,6 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
switch row.Key {
|
||||
case "penalties_enabled":
|
||||
settings.PenaltiesEnabled = row.Value == "true"
|
||||
case "penalty_tiers":
|
||||
var tiers []models.PenaltyTier
|
||||
if err := json.Unmarshal([]byte(row.Value), &tiers); err == nil {
|
||||
settings.PenaltyTiers = tiers
|
||||
}
|
||||
case "show_amende_score":
|
||||
settings.ShowAmendeScore = row.Value == "true"
|
||||
case "points_enabled":
|
||||
@@ -129,33 +111,6 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
|
||||
settings.PointsPools = pools
|
||||
}
|
||||
case "points_reward":
|
||||
// row.Value peut valoir la chaîne littérale "null" (récompense
|
||||
// désactivée puis sauvegardée : json.Marshal(nil *PointsReward)
|
||||
// produit "null"). json.Unmarshal d'un null JSON dans une valeur
|
||||
// non-pointeur est un no-op sans erreur (voir doc encoding/json),
|
||||
// donc sans ce garde-fou &reward pointerait vers une struct vide
|
||||
// mais non-nil, et la récompense réapparaîtrait activée.
|
||||
if row.Value != "null" && row.Value != "" {
|
||||
var reward models.PointsReward
|
||||
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
|
||||
settings.PointsReward = &reward
|
||||
}
|
||||
}
|
||||
case "promotions_enabled":
|
||||
settings.PromotionsEnabled = row.Value == "true"
|
||||
case "promotions":
|
||||
var promotions []models.CategoryPromotionConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
|
||||
settings.Promotions = promotions
|
||||
}
|
||||
case "free_gifts_enabled":
|
||||
settings.FreeGiftsEnabled = row.Value == "true"
|
||||
case "free_gifts":
|
||||
var freeGifts []models.CategoryFreeGiftConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &freeGifts); err == nil {
|
||||
settings.FreeGifts = freeGifts
|
||||
}
|
||||
case "referral_enabled":
|
||||
settings.ReferralEnabled = row.Value == "true"
|
||||
case "referral_amount":
|
||||
@@ -185,8 +140,6 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
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":
|
||||
@@ -202,30 +155,6 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
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
|
||||
@@ -240,14 +169,6 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
return "false"
|
||||
}
|
||||
|
||||
if s.PenaltyTiers == nil {
|
||||
s.PenaltyTiers = []models.PenaltyTier{}
|
||||
}
|
||||
tiersJSON, err := json.Marshal(s.PenaltyTiers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation penalty_tiers: %w", err)
|
||||
}
|
||||
|
||||
if s.PointsPools == nil {
|
||||
s.PointsPools = []models.PointsPool{}
|
||||
}
|
||||
@@ -265,52 +186,6 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
return fmt.Errorf("erreur sérialisation pools: %w", err)
|
||||
}
|
||||
|
||||
if s.PointsReward != nil {
|
||||
for i := range s.PointsReward.CategoryConfigs {
|
||||
if s.PointsReward.CategoryConfigs[i].Products == nil {
|
||||
s.PointsReward.CategoryConfigs[i].Products = []models.RewardProductQuantity{}
|
||||
}
|
||||
}
|
||||
}
|
||||
rewardJSON, err := json.Marshal(s.PointsReward)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
|
||||
}
|
||||
|
||||
if s.Promotions == nil {
|
||||
s.Promotions = []models.CategoryPromotionConfig{}
|
||||
}
|
||||
for i := range s.Promotions {
|
||||
if s.Promotions[i].Products == nil {
|
||||
s.Promotions[i].Products = []models.PromotionProductQuantity{}
|
||||
}
|
||||
}
|
||||
promotionsJSON, err := json.Marshal(s.Promotions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation promotions: %w", err)
|
||||
}
|
||||
|
||||
if s.FreeGifts == nil {
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{}
|
||||
}
|
||||
for i := range s.FreeGifts {
|
||||
if s.FreeGifts[i].Tiers == nil {
|
||||
s.FreeGifts[i].Tiers = []models.FreeGiftTier{}
|
||||
}
|
||||
if s.FreeGifts[i].Products == nil {
|
||||
s.FreeGifts[i].Products = []models.FreeGiftProductQuantity{}
|
||||
}
|
||||
for j := range s.FreeGifts[i].Products {
|
||||
if s.FreeGifts[i].Products[j].Tiers == nil {
|
||||
s.FreeGifts[i].Products[j].Tiers = []models.FreeGiftTier{}
|
||||
}
|
||||
}
|
||||
}
|
||||
freeGiftsJSON, err := json.Marshal(s.FreeGifts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation free_gifts: %w", err)
|
||||
}
|
||||
|
||||
if s.NowPaymentsCurrencies == nil {
|
||||
s.NowPaymentsCurrencies = []string{}
|
||||
}
|
||||
@@ -340,20 +215,11 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
|
||||
}
|
||||
|
||||
if s.ContactTelegram == "" {
|
||||
s.ContactTelegram = "MLN44LA"
|
||||
}
|
||||
pairs := [][2]string{
|
||||
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
|
||||
{"penalty_tiers", string(tiersJSON)},
|
||||
{"show_amende_score", boolStr(s.ShowAmendeScore)},
|
||||
{"points_enabled", boolStr(s.PointsEnabled)},
|
||||
{"points_pools", string(poolsJSON)},
|
||||
{"points_reward", string(rewardJSON)},
|
||||
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
|
||||
{"promotions", string(promotionsJSON)},
|
||||
{"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)},
|
||||
{"free_gifts", string(freeGiftsJSON)},
|
||||
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
||||
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
||||
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
||||
@@ -369,19 +235,6 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
{"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 (?, ?)
|
||||
|
||||
@@ -1,495 +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
|
||||
}
|
||||
|
||||
// TotalPromoDiscount renvoie le montant total (€) des réductions de prix
|
||||
// accordées par des promotions sur les commandes approuvées, filtré par le
|
||||
// reset "revenus" (même périmètre que TotalRevenue, dont c'est un
|
||||
// sous-indicateur). Basé sur command_items.promo_discount, capturé au moment
|
||||
// de AddToBasket — reflète donc les promos réellement appliquées à l'époque,
|
||||
// pas la config de promotions courante.
|
||||
func (d *Database) TotalPromoDiscount(resetAt time.Time) (float64, error) {
|
||||
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
|
||||
var total float64
|
||||
query := `
|
||||
SELECT COALESCE(SUM(ci.promo_discount), 0)
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
WHERE ` + where
|
||||
err := d.GDB.Raw(query, args...).Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// PromoOrdersCount renvoie le nombre de commandes distinctes (approuvées)
|
||||
// ayant bénéficié d'au moins une réduction de prix promo, filtré par le
|
||||
// reset "revenus".
|
||||
func (d *Database) PromoOrdersCount(resetAt time.Time) (int64, error) {
|
||||
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
|
||||
var count int64
|
||||
query := `
|
||||
SELECT COUNT(DISTINCT ci.command_id)
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
WHERE ci.promo_discount > 0 AND ` + where
|
||||
err := d.GDB.Raw(query, args...).Scan(&count).Error
|
||||
return count, 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
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func GenerateLinkToken(username, role string) (string, error) {
|
||||
|
||||
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 "", fmt.Errorf("Redis SET: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -109,45 +109,6 @@ 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 {
|
||||
|
||||
@@ -15,7 +15,7 @@ func (d *Database) CreateUser(user *models.User) error {
|
||||
|
||||
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 {
|
||||
if err := d.GDB.Order("created_at DESC").Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
|
||||
}
|
||||
return users, nil
|
||||
@@ -23,7 +23,7 @@ func (d *Database) GetAllUsers() ([]*models.User, error) {
|
||||
|
||||
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 {
|
||||
if err := d.GDB.Where("role = ?", "livreur").Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
|
||||
}
|
||||
return users, nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// isNotFound retourne true si l'erreur GORM est un "record not found"
|
||||
func isNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
|
||||
func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
|
||||
keys, err := scanRedisKeys("delivery:status:*")
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil || len(keys) == 0 {
|
||||
return "", fmt.Errorf("aucun livreur trouvé")
|
||||
}
|
||||
@@ -67,6 +67,57 @@ func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
|
||||
return leastLoaded, nil
|
||||
}
|
||||
|
||||
// FindAvailableOrLeastLoadedDeliveryman trouve un livreur disponible ou le moins chargé
|
||||
func (d *Database) FindAvailableOrLeastLoadedDeliveryman() (string, string, int, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil || len(keys) == 0 {
|
||||
return "", "", 0, fmt.Errorf("aucun livreur trouvé")
|
||||
}
|
||||
|
||||
var bestDeliveryman string
|
||||
var bestStatus string
|
||||
bestQueueSize := 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
|
||||
}
|
||||
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
continue
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if status.Status == "available" && queueSize == 0 {
|
||||
return username, "available", 0, nil
|
||||
}
|
||||
|
||||
if queueSize < bestQueueSize {
|
||||
bestQueueSize = queueSize
|
||||
bestDeliveryman = username
|
||||
bestStatus = status.Status
|
||||
}
|
||||
}
|
||||
|
||||
if bestDeliveryman == "" {
|
||||
return "", "", 0, fmt.Errorf("tous les livreurs sont au maximum de leur capacité")
|
||||
}
|
||||
|
||||
return bestDeliveryman, bestStatus, int(bestQueueSize), nil
|
||||
}
|
||||
|
||||
// GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
|
||||
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
|
||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||
|
||||
@@ -156,6 +156,10 @@ func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int
|
||||
return services.CalculateETA(distance)
|
||||
}
|
||||
|
||||
func (d *Database) GetDeliverymanQueueStats(deliveryman string) (map[string]any, error) {
|
||||
return d.GetDeliverymanQueueInfo(deliveryman)
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
@@ -165,7 +169,6 @@ func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition i
|
||||
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(),
|
||||
|
||||
@@ -87,6 +87,35 @@ func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman st
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignCommandToDeliverymanQueueUnlimited assigne sans limite (pour un seul livreur)
|
||||
func (d *Database) AssignCommandToDeliverymanQueueUnlimited(deliveryman string, queueItem models.CommandQueue) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
travelTime := d.CalculateETAForDeliveryman(deliveryman, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
queueItem.EstimatedETA = travelTime
|
||||
|
||||
err := d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(queueItem.CommandID, "assigned")
|
||||
d.AssignDeliveryPerson(queueItem.CommandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(queueItem.CommandID, travelTime, int(currentQueueSize)+1)
|
||||
|
||||
d.AddCommandLog(queueItem.CommandID, "queued",
|
||||
fmt.Sprintf("Assigné au seul livreur actif %s (position: %d, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, travelTime),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d -> Queue %s (SANS LIMITE - pos: %d, ETA trajet: %d min)",
|
||||
queueItem.CommandID, deliveryman, currentQueueSize+1, travelTime)
|
||||
|
||||
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)
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CleanupInvalidQueueCommands supprime toutes les commandes avec des données manquantes
|
||||
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
||||
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
|
||||
|
||||
keys, err := scanRedisKeys("queue:pending:*")
|
||||
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération des clés: %w", err)
|
||||
}
|
||||
@@ -81,6 +82,7 @@ func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
||||
return removedCount, nil
|
||||
}
|
||||
|
||||
// removeInvalidCommand supprime une commande invalide de toutes les queues
|
||||
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
@@ -92,7 +94,7 @@ func (d *Database) removeInvalidCommand(key string, commandID int, reason string
|
||||
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||
|
||||
// 3. Supprimer des queues de livreurs
|
||||
livreurKeys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
livreurKeys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
for _, queueKey := range livreurKeys {
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
@@ -201,3 +203,64 @@ func (d *Database) StartQueueCleanupScheduler() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RAPPORT DE VALIDATION
|
||||
// ============================================
|
||||
|
||||
// GetQueueValidationReport génère un rapport de validation sans supprimer
|
||||
func (d *Database) GetQueueValidationReport() (map[string]any, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report := map[string]any{
|
||||
"total_commands": len(keys),
|
||||
"valid_commands": 0,
|
||||
"invalid_commands": 0,
|
||||
"invalid_details": []map[string]any{},
|
||||
"validation_results": []string{},
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
report["invalid_commands"] = report["invalid_commands"].(int) + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Validation
|
||||
issues := []string{}
|
||||
if queueItem.Username == "" {
|
||||
issues = append(issues, "username vide")
|
||||
}
|
||||
if queueItem.Address == "" {
|
||||
issues = append(issues, "adresse vide")
|
||||
}
|
||||
if queueItem.Lat == 0 || queueItem.Lng == 0 {
|
||||
issues = append(issues, "GPS manquant")
|
||||
}
|
||||
if queueItem.CreatedAt.IsZero() {
|
||||
issues = append(issues, "date invalide")
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
report["invalid_commands"] = report["invalid_commands"].(int) + 1
|
||||
report["invalid_details"] = append(report["invalid_details"].([]map[string]any), map[string]any{
|
||||
"command_id": queueItem.CommandID,
|
||||
"issues": issues,
|
||||
"data": queueItem,
|
||||
})
|
||||
} else {
|
||||
report["valid_commands"] = report["valid_commands"].(int) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
@@ -68,9 +68,8 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
|
||||
overview["general_queue"] = generalQueueSize
|
||||
|
||||
deliverymanQueues := make(map[string]any)
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
|
||||
var totalPending int64 = generalQueueSize
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
@@ -84,19 +83,29 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
|
||||
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"capacity": fmt.Sprintf("%d/%d", queueSize, MAX_COMMANDS_PER_DELIVERYMAN),
|
||||
}
|
||||
totalPending += queueSize
|
||||
}
|
||||
|
||||
overview["deliveryman_queues"] = deliverymanQueues
|
||||
var totalPending int64 = generalQueueSize
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
size, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
totalPending += size
|
||||
}
|
||||
overview["total_pending"] = totalPending
|
||||
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
// GetQueueStats - Statistiques détaillées
|
||||
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:*")
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
@@ -108,8 +117,7 @@ func (d *Database) GetQueueStats() (map[string]any, error) {
|
||||
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()
|
||||
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, -1).Result()
|
||||
for _, result := range normalResults {
|
||||
commandID := extractCommandID(result.Member)
|
||||
if commandID <= 0 {
|
||||
|
||||
@@ -86,6 +86,40 @@ func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsForAssignment récupère UNIQUEMENT les livreurs pouvant accepter
|
||||
func (d *Database) GetAvailableDeliveryPersonsForAssignment() ([]models.DeliveryPersonStatus, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var available []models.DeliveryPersonStatus
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if d.CanDeliverymanAcceptCommands(status.Username) {
|
||||
available = append(available, status)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📊 [AVAILABLE] %d livreur(s) disponible(s) pour assignation", len(available))
|
||||
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔄 FONCTIONS MODIFIÉES AVEC AUTO-STATUS
|
||||
// ============================================
|
||||
|
||||
// AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
|
||||
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
@@ -116,6 +150,111 @@ func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.Co
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddCommandToQueue ajoute une commande à la file d'attente Redis (version simple)
|
||||
func (d *Database) AddCommandToQueue(commandID int) error {
|
||||
if err := d.ValidateCommandBeforeQueue(commandID); err != nil {
|
||||
log.Printf("❌ [QUEUE] Commande %d REFUSÉE: %v", commandID, err)
|
||||
return fmt.Errorf("validation échouée: %w", err)
|
||||
}
|
||||
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: 0,
|
||||
}
|
||||
|
||||
return d.AddToGeneralQueue(queueItem)
|
||||
}
|
||||
|
||||
// AddCommandToSmartQueue - Ajoute une commande avec attribution au livreur le moins chargé
|
||||
func (d *Database) AddCommandToSmartQueue(commandID int, address string) error {
|
||||
if err := d.ValidateCommandBeforeQueue(commandID); err != nil {
|
||||
log.Printf("❌ [QUEUE] Commande %d REFUSÉE: %v", commandID, err)
|
||||
return fmt.Errorf("validation échouée: %w", err)
|
||||
}
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: 0,
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: Utiliser FindLeastLoadedDeliveryman qui respecte maintenant le statut
|
||||
assignedDeliveryman, err := d.FindLeastLoadedDeliveryman()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Aucun livreur trouvé, ajout à la queue générale")
|
||||
return d.AddToGeneralQueue(queueItem)
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(assignedDeliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue du livreur: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📋 Commande %d assignée à la queue de %s", commandID, assignedDeliveryman)
|
||||
|
||||
d.PublishCommandEvent(commandID, "queued",
|
||||
fmt.Sprintf("En attente dans la queue de %s", assignedDeliveryman))
|
||||
|
||||
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)
|
||||
@@ -142,6 +281,7 @@ func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCommandFromQueue - VERSION AMÉLIORÉE avec auto-update du statut
|
||||
func (d *Database) RemoveCommandFromQueue(commandID int) error {
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
commandIDStr := strconv.Itoa(commandID)
|
||||
@@ -152,7 +292,7 @@ func (d *Database) RemoveCommandFromQueue(commandID int) error {
|
||||
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||
|
||||
// Trouver et retirer de la queue du livreur
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
var affectedDeliveryman string
|
||||
|
||||
for _, queueKey := range keys {
|
||||
@@ -215,6 +355,109 @@ func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
|
||||
return &queue, nil
|
||||
}
|
||||
|
||||
// GetLastCommandInQueue récupère la dernière commande dans la queue d'un livreur
|
||||
func (d *Database) GetLastCommandInQueue(deliveryman string) (*models.CommandQueue, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Récupérer la dernière commande (index -1)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, -1, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil, fmt.Errorf("queue vide")
|
||||
}
|
||||
|
||||
commandID := extractCommandID(commandIDs[0])
|
||||
if commandID <= 0 {
|
||||
return nil, fmt.Errorf("ID invalide")
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &queueItem, nil
|
||||
}
|
||||
|
||||
// GetCommandQueuePosition récupère la position d'une commande dans la queue
|
||||
func (d *Database) GetCommandQueuePosition(commandID int) (int, error) {
|
||||
commandIDStr := strconv.Itoa(commandID)
|
||||
|
||||
// Chercher d'abord dans les queues des livreurs
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
|
||||
for _, queueKey := range keys {
|
||||
// Éviter les clés de compteur
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err == nil {
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Chercher dans la queue générale
|
||||
rank, err := Redis.ZRank(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
|
||||
if err == nil {
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("commande non trouvée dans les queues")
|
||||
}
|
||||
|
||||
func (d *Database) ClearDeliverymanQueue(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Récupérer toutes les commandes
|
||||
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
// Redistribuer chaque commande
|
||||
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
|
||||
}
|
||||
|
||||
newDeliveryman, err := d.FindLeastLoadedDeliveryman()
|
||||
if err != nil {
|
||||
d.AddToGeneralQueue(queueItem)
|
||||
continue
|
||||
}
|
||||
|
||||
if newDeliveryman != deliveryman {
|
||||
d.AddToDeliverymanQueue(newDeliveryman, queueItem)
|
||||
log.Printf("🔄 Commande %d réassignée de %s à %s",
|
||||
commandID, deliveryman, newDeliveryman)
|
||||
}
|
||||
}
|
||||
|
||||
// Vider la queue
|
||||
Redis.Del(RedisCtx, queueKey)
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
|
||||
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
|
||||
key := fmt.Sprintf("delivery:status:%s", username)
|
||||
|
||||
@@ -300,9 +543,49 @@ func (d *Database) SyncAllDeliverymanStatuses() error {
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliverymanCapacityReport génère un rapport détaillé
|
||||
func (d *Database) GetDeliverymanCapacityReport() (map[string]any, error) {
|
||||
report := map[string]any{
|
||||
"total_deliverymen": 0,
|
||||
"available": 0,
|
||||
"busy_full": 0,
|
||||
"busy_delivering": 0,
|
||||
"offline": 0,
|
||||
"details": []map[string]any{},
|
||||
}
|
||||
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", s.Username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
canAccept := d.CanDeliverymanAcceptCommands(s.Username)
|
||||
|
||||
report["total_deliverymen"] = report["total_deliverymen"].(int) + 1
|
||||
switch {
|
||||
case s.Status == "offline":
|
||||
report["offline"] = report["offline"].(int) + 1
|
||||
case s.Status == "busy" && queueSize >= MAX_COMMANDS_PER_DELIVERYMAN:
|
||||
report["busy_full"] = report["busy_full"].(int) + 1
|
||||
case s.Status == "busy":
|
||||
report["busy_delivering"] = report["busy_delivering"].(int) + 1
|
||||
case canAccept:
|
||||
report["available"] = report["available"].(int) + 1
|
||||
}
|
||||
|
||||
report["details"] = append(report["details"].([]map[string]any), map[string]any{
|
||||
"username": s.Username,
|
||||
"status": s.Status,
|
||||
"queue_size": queueSize,
|
||||
"capacity": fmt.Sprintf("%d/10", queueSize),
|
||||
"can_accept": canAccept,
|
||||
"current_order": s.CurrentCommand,
|
||||
})
|
||||
})
|
||||
return report, err
|
||||
}
|
||||
|
||||
// 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:*")
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -319,20 +602,3 @@ func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) er
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -288,6 +288,10 @@ func (d *Database) RecalculateQueueETAs(deliveryman string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateQueueETAsAfterCompletion(deliveryman string) error {
|
||||
return d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
@@ -182,3 +183,221 @@ func (d *Database) InvalidateSession(clientID int) error {
|
||||
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PANIER EN CACHE REDIS
|
||||
// ============================================
|
||||
|
||||
// BasketItemCache représente un item du panier en cache
|
||||
type BasketItemCache struct {
|
||||
ID int `json:"id"`
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Category string `json:"category"`
|
||||
AddedAt int64 `json:"added_at"`
|
||||
}
|
||||
|
||||
// GetSessionBasket récupère le panier en cache Redis
|
||||
// Retourne les items du panier avec total
|
||||
func (d *Database) GetSessionBasket(clientID int) ([]BasketItemCache, float64, error) {
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
|
||||
// Récupérer tous les items du panier
|
||||
items, err := Redis.HGetAll(RedisCtx, basketKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [BASKET] Pas de panier en cache pour client %d", clientID)
|
||||
return []BasketItemCache{}, 0, nil
|
||||
}
|
||||
|
||||
var basketItems []BasketItemCache
|
||||
var totalPrice float64
|
||||
|
||||
for _, itemJSON := range items {
|
||||
var item BasketItemCache
|
||||
if err := json.Unmarshal([]byte(itemJSON), &item); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur parsing item: %v", err)
|
||||
continue
|
||||
}
|
||||
basketItems = append(basketItems, item)
|
||||
totalPrice += item.Price * float64(item.Quantity)
|
||||
}
|
||||
|
||||
return basketItems, totalPrice, nil
|
||||
}
|
||||
|
||||
// UpdateSessionBasket met à jour le panier en cache Redis
|
||||
// Appelé après ajout/modification d'un produit au panier
|
||||
func (d *Database) UpdateSessionBasket(clientID int, basketItems []BasketItemCache) error {
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
|
||||
// Vider le panier existant
|
||||
Redis.Del(RedisCtx, basketKey)
|
||||
|
||||
// Ajouter tous les items
|
||||
for _, item := range basketItems {
|
||||
itemJSON, _ := json.Marshal(item)
|
||||
if err := Redis.HSet(RedisCtx, basketKey, item.ProductID, itemJSON).Err(); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur ajout item: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TTL: 24 heures
|
||||
if err := Redis.Expire(RedisCtx, basketKey, 24*time.Hour).Err(); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur TTL: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSessionBasket vide le panier en cache Redis
|
||||
// Appelé après validation de commande (checkout)
|
||||
func (d *Database) ClearSessionBasket(clientID int) error {
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
if err := Redis.Del(RedisCtx, basketKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur clear: %v", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("✅ [BASKET] Panier vidé pour client %d", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITAIRES SESSION
|
||||
// ============================================
|
||||
|
||||
// GetAllActiveSessions récupère toutes les sessions actives
|
||||
// Utile pour admin/stats
|
||||
func (d *Database) GetAllActiveSessions() ([]SessionData, error) {
|
||||
clientIDs, err := Redis.SMembers(RedisCtx, "session:active:clients").Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération sessions: %w", err)
|
||||
}
|
||||
|
||||
var sessions []SessionData
|
||||
for _, clientIDStr := range clientIDs {
|
||||
var clientID int
|
||||
if _, err := fmt.Sscanf(clientIDStr, "%d", &clientID); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if session, err := d.GetClientSession(clientID); err == nil {
|
||||
sessions = append(sessions, *session)
|
||||
}
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// GetSessionCount retourne le nombre de sessions actives
|
||||
func (d *Database) GetSessionCount() (int64, error) {
|
||||
count, err := Redis.SCard(RedisCtx, "session:active:clients").Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur comptage sessions: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CACHE PROFIL CLIENT
|
||||
// ============================================
|
||||
|
||||
// CacheClientProfile met en cache les infos du client (pour 1h)
|
||||
func (d *Database) CacheClientProfile(client interface{}) error {
|
||||
// Récupérer le client depuis DB si c'est un username
|
||||
var clientData *models.Client
|
||||
|
||||
// Si c'est un username string
|
||||
if username, ok := client.(string); ok {
|
||||
var err error
|
||||
clientData, err = d.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client non trouvé: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Si c'est déjà un *models.Client
|
||||
clientData = client.(*models.Client)
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientData.ID)
|
||||
|
||||
// Sérialiser
|
||||
profileJSON, err := json.Marshal(clientData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder avec TTL 1h
|
||||
if err := Redis.Set(RedisCtx, cacheKey, profileJSON, 1*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur cache: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [CACHE] Profil client %d mis en cache (1h)", clientData.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedClientProfile récupère le profil en cache
|
||||
func (d *Database) GetCachedClientProfile(clientID int) (*models.Client, error) {
|
||||
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientID)
|
||||
|
||||
data, err := Redis.Get(RedisCtx, cacheKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache miss")
|
||||
}
|
||||
|
||||
var client models.Client
|
||||
if err := json.Unmarshal([]byte(data), &client); err != nil {
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
// InvalidateClientCache invalide le cache du client
|
||||
func (d *Database) InvalidateClientCache(clientID int) error {
|
||||
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientID)
|
||||
if err := Redis.Del(RedisCtx, cacheKey).Err(); err != nil {
|
||||
return fmt.Errorf("erreur invalidation: %w", err)
|
||||
}
|
||||
log.Printf("✅ [CACHE] Profil client %d invalidé", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COMMANDES EN CACHE (POUR TRACKING)
|
||||
// ============================================
|
||||
|
||||
// CacheCommandInfo met en cache les infos d'une commande
|
||||
func (d *Database) CacheCommandInfo(commandID int, command map[string]interface{}) error {
|
||||
cacheKey := fmt.Sprintf("cache:command:%d", commandID)
|
||||
|
||||
commandJSON, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation: %w", err)
|
||||
}
|
||||
|
||||
// TTL: 4 heures
|
||||
if err := Redis.Set(RedisCtx, cacheKey, commandJSON, 4*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedCommand récupère une commande en cache
|
||||
func (d *Database) GetCachedCommand(commandID int) (map[string]interface{}, error) {
|
||||
cacheKey := fmt.Sprintf("cache:command:%d", commandID)
|
||||
|
||||
data, err := Redis.Get(RedisCtx, cacheKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache miss")
|
||||
}
|
||||
|
||||
var command map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(data), &command); err != nil {
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
+1
-19
@@ -13,30 +13,11 @@ require (
|
||||
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
|
||||
@@ -74,6 +55,7 @@ require (
|
||||
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/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
|
||||
@@ -1,39 +1,3 @@
|
||||
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=
|
||||
|
||||
@@ -27,6 +27,7 @@ func AlertPolice(c *gin.Context) {
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
// message optionnel — on ignore l'erreur de bind
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
usernameStr := username.(string)
|
||||
@@ -60,25 +61,6 @@ func DeleteAlert(c *gin.Context) {
|
||||
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
|
||||
@@ -110,15 +92,6 @@ func GetAlert(c *gin.Context) {
|
||||
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,
|
||||
|
||||
@@ -55,13 +55,13 @@ func generateAdminToken(user *models.User) (string, error) {
|
||||
claims := models.AdminClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
Role: user.Role, // ← "admin" ou "cabine" ou "livreur"
|
||||
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",
|
||||
Issuer: "api-admin", // Même issuer pour tous les admins
|
||||
Subject: strconv.Itoa(user.ID),
|
||||
},
|
||||
}
|
||||
@@ -73,13 +73,114 @@ func generateAdminToken(user *models.User) (string, error) {
|
||||
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"})
|
||||
// RegisterClient crée un nouveau compte client
|
||||
func RegisterClient(c *gin.Context) {
|
||||
var req models.RegisterClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize text inputs
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
|
||||
// Validation téléphone
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [REGISTER_CLIENT] 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)
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Vérifier username unique
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier téléphone unique
|
||||
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] 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
|
||||
}
|
||||
|
||||
// Hasher le mot de passe
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
// Créer le client
|
||||
client := &models.Client{
|
||||
Username: req.Username,
|
||||
Password: string(hashed),
|
||||
Nom: strings.TrimSpace(req.Nom),
|
||||
Prenom: strings.TrimSpace(req.Prenom),
|
||||
Telephone: normalizedPhone,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := database.CreateClient(client); err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
|
||||
return
|
||||
}
|
||||
|
||||
// Générer le token
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur génération token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder le token
|
||||
expiresAt := time.Now().Add(clientTokenDuration)
|
||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Créer la session Redis
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [REGISTER_CLIENT] Erreur session Redis: %v", err)
|
||||
}
|
||||
|
||||
client.Password = ""
|
||||
|
||||
c.JSON(http.StatusCreated, 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
|
||||
func AdminCreateClient(c *gin.Context) {
|
||||
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)
|
||||
@@ -186,18 +287,15 @@ func LoginClient(c *gin.Context) {
|
||||
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"})
|
||||
if err := db.Store2FASession(sessionToken, client.Username, code); err == nil {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +454,6 @@ func ToggleClient2FA(c *gin.Context) {
|
||||
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"`
|
||||
@@ -419,6 +516,60 @@ func LogoutClient(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
|
||||
}
|
||||
|
||||
// RegisterAdmin crée un nouvel utilisateur admin/cabine/livreur
|
||||
func RegisterAdmin(c *gin.Context) {
|
||||
var req models.RegisterAdminRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user := &models.User{
|
||||
Username: req.Username,
|
||||
Password: string(hashed),
|
||||
Role: req.Role,
|
||||
}
|
||||
|
||||
if err := database.CreateUser(user); err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création utilisateur"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateAdminToken(user)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur génération token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(adminTokenDuration)
|
||||
if err := database.SaveToken(user.ID, user.Role, token, expiresAt); err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = ""
|
||||
|
||||
c.JSON(http.StatusCreated, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(adminTokenDuration.Seconds()),
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// LoginAdmin authentifie un admin/cabine/livreur
|
||||
func LoginAdmin(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
@@ -455,12 +606,6 @@ func LoginAdmin(c *gin.Context) {
|
||||
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)
|
||||
@@ -502,6 +647,86 @@ func LogoutAdmin(c *gin.Context) {
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
// GetCurrentClient récupère le client actuel
|
||||
// GET /api/v1/profile/client
|
||||
func GetCurrentClient(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_CURRENT_CLIENT] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
client.Password = ""
|
||||
|
||||
log.Printf("✅ [GET_CURRENT_CLIENT] Client récupéré: %s", client.Username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"client": gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"command": client.Command,
|
||||
"amende": client.Amende,
|
||||
"points_extra": client.PointsExtra,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetCurrentAdmin récupère l'admin/user actuel
|
||||
// GET /api/v1/profile/admin
|
||||
func GetCurrentAdmin(c *gin.Context) {
|
||||
userID := c.GetInt("user_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
user, err := database.GetUserByID(userID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_CURRENT_ADMIN] User non trouvé: ID=%d", userID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = ""
|
||||
|
||||
log.Printf("✅ [GET_CURRENT_ADMIN] User récupéré: %s", user.Username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": models.ProfileResponse{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HealthCheck vérifie la santé de l'API
|
||||
// GET /api/v1/health
|
||||
func HealthCheck(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.DB.Ping(); err != nil {
|
||||
log.Printf("⚠️ [HEALTH] Database down: %v", err)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "unhealthy",
|
||||
"database": "disconnected",
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [HEALTH] API healthy")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "healthy",
|
||||
"database": "connected",
|
||||
"timestamp": time.Now().Unix(),
|
||||
"version": "2.0.0",
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
||||
func GetAllUsers(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
@@ -669,39 +894,18 @@ func CreateUser(c *gin.Context) {
|
||||
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"})
|
||||
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
|
||||
}
|
||||
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)
|
||||
err := database.CreateUser(&user)
|
||||
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)
|
||||
log.Printf("✅ [CREATE_USER] Utilisateur %d créé", user.ID)
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
|
||||
}
|
||||
|
||||
func Health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,244 @@
|
||||
// ============================================
|
||||
// handlers/cabine_handlers.go - COMPLET
|
||||
// INCLUT: SetCommandDestinationCoordinates
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 0️⃣ FONCTION ADMIN: SET DESTINATION COORDINATES
|
||||
// ============================================
|
||||
|
||||
// SetCommandDestinationCoordinates stocke les coordonnées destination en Redis
|
||||
func SetCommandDestinationCoordinates(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 réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername := c.GetString("username")
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
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": "Latitude et longitude requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validation des coordonnées GPS
|
||||
if req.Latitude < -90 || req.Latitude > 90 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Latitude invalide (doit être entre -90 et 90)",
|
||||
"value": req.Latitude,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Longitude < -180 || req.Longitude > 180 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Longitude invalide (doit être entre -180 et 180)",
|
||||
"value": req.Longitude,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckCommand(commandID, database) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
}
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": req.Latitude,
|
||||
"lon": req.Longitude,
|
||||
})
|
||||
|
||||
ttlSeconds := 24 * 60 * 60 // 24 heures
|
||||
err = db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, time.Duration(ttlSeconds)*time.Second).Err()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur stockage Redis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Ajouter un log
|
||||
database.AddCommandLog(commandID, "destination_set",
|
||||
fmt.Sprintf("Coordonnées destination définies par admin %s: (%.6f, %.6f) via Redis",
|
||||
adminUsername, req.Latitude, req.Longitude),
|
||||
adminUsername)
|
||||
|
||||
log.Printf("✅ [ADMIN %s] Coordonnées destination définies pour CMD %d: (%.6f, %.6f) en Redis",
|
||||
adminUsername, commandID, req.Latitude, req.Longitude)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Coordonnées définies avec succès en Redis",
|
||||
"command_id": commandID,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 1. CLIENT PROFILE
|
||||
// ============================================
|
||||
|
||||
func GetClientProfile(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
client.Password = ""
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"client": gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"command": client.Command,
|
||||
"amende": client.Amende,
|
||||
"points_extra": client.PointsExtra,
|
||||
"created_at": client.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetClientFullHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
commands, err := database.GetAllCommands("", username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération historique"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"client": gin.H{
|
||||
"username": client.Username,
|
||||
"total_commands": client.Command,
|
||||
"amende": client.Amende,
|
||||
"points_extra": client.PointsExtra,
|
||||
},
|
||||
"commands": commands,
|
||||
"count": len(commands),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. UPDATE ADDRESS
|
||||
// ============================================
|
||||
|
||||
func UpdateCommandAddressCabine(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
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
status, _ := command["status"].(string)
|
||||
|
||||
allowedStatuses := []string{"pending", "", "assigned"}
|
||||
if !slices.Contains(allowedStatuses, status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de modifier l'adresse d'une commande en cours ou terminée",
|
||||
"current_status": status,
|
||||
"allowed_statuses": allowedStatuses,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateCommandAddress(commandID, req.DeliveryAddress); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la mise à jour de l'adresse",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
message := fmt.Sprintf("Adresse modifiée par cabine: %s", req.DeliveryAddress)
|
||||
if req.Reason != "" {
|
||||
message += fmt.Sprintf(" (Raison: %s)", req.Reason)
|
||||
}
|
||||
database.AddCommandLog(commandID, "address_updated", message, cabineUsername.(string))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Adresse de livraison mise à jour",
|
||||
"command_id": commandID,
|
||||
"delivery_address": req.DeliveryAddress,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. LIVREUR POSITION
|
||||
// ============================================
|
||||
|
||||
func GetLivreurPosition(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
livreurUsername := c.Param("username")
|
||||
@@ -42,6 +273,108 @@ func GetLivreurPosition(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func GetDeliveryTrackingClient(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
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
if command["username"].(string) != username.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
logs, _ := database.GetCommandLogs(commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": command["status"],
|
||||
"livreur": livreurAssign,
|
||||
"address": command["adresse"],
|
||||
"logs": logs,
|
||||
"message": "Suivi en cours - ETA disponible via /api/v1/orders/:id/eta",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5. DELIVERY TRACKING ADMIN (AVEC GPS)
|
||||
// ============================================
|
||||
|
||||
func GetDeliveryTracking(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é - Réservé aux administrateurs et cabines",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande 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 == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command": command,
|
||||
"status": "Aucun livreur assigné",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
position, err := database.GetLivreurPosition(livreurAssign)
|
||||
logs, _ := database.GetCommandLogs(commandID)
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"command": command,
|
||||
"livreur": livreurAssign,
|
||||
"logs": logs,
|
||||
}
|
||||
|
||||
status, _ := command["status"].(string)
|
||||
if err != nil && (status == "livre" || status == "approved") {
|
||||
response["livreur_position"] = nil
|
||||
response["position_status"] = "Livraison terminée - Position non suivie"
|
||||
} else if err != nil {
|
||||
response["livreur_position"] = nil
|
||||
response["position_status"] = "Position non disponible (GPS peut-être désactivé)"
|
||||
} else {
|
||||
response["livreur_position"] = position
|
||||
response["position_status"] = "Position en temps réel"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func GetDeliveryIssues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -57,7 +390,7 @@ func GetDeliveryIssues(c *gin.Context) {
|
||||
issues, err := database.GetDeliveryIssues(status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération problèmes",
|
||||
"error": "Erreur récupération problèmes",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -93,7 +426,7 @@ func CreateDeliveryIssue(c *gin.Context) {
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur création problème",
|
||||
"error": "Erreur création problème",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -129,7 +462,7 @@ func UpdateDeliveryIssue(c *gin.Context) {
|
||||
err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -140,6 +473,53 @@ func UpdateDeliveryIssue(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func AddDeliverySupport(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
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
if req.Message == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Message requis",
|
||||
"example": gin.H{
|
||||
"message": "Votre message de support ici",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
|
||||
err = database.AddCommandLog(
|
||||
commandID,
|
||||
"note",
|
||||
fmt.Sprintf("Note cabine: %s", req.Message),
|
||||
cabineUsername.(string),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur ajout support",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Support ajouté",
|
||||
})
|
||||
}
|
||||
|
||||
func GetCommandLogs(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -152,7 +532,7 @@ func GetCommandLogs(c *gin.Context) {
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération logs",
|
||||
"error": "Erreur récupération logs",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -163,3 +543,127 @@ func GetCommandLogs(c *gin.Context) {
|
||||
"count": len(logs),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 7. FORCE VALIDATE DELIVERY
|
||||
// ============================================
|
||||
|
||||
func ForceValidateDelivery(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é - Admin seulement"})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Raison requise pour validation forcée",
|
||||
"example": gin.H{
|
||||
"reason": "Client confirmé par téléphone",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Reason == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Veuillez fournir une raison pour la validation forcée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Commande non trouvée",
|
||||
"command_id": commandID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status, ok := command["status"].(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Statut de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if status == "livre" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cette commande a déjà été validée",
|
||||
"current_status": status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := []string{"assigned", "en_route", "pending", "priority"}
|
||||
if !slices.Contains(validStatuses, status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Commande ne peut pas être validée de force dans ce statut",
|
||||
"current_status": status,
|
||||
"valid_statuses": validStatuses,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.UpdateCommandStatus(commandID, "livre")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la validation forcée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
clientUsername, _ := command["username"].(string)
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
|
||||
if clientUsername != "" {
|
||||
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))
|
||||
database.NotifyClient(clientUsername, commandID, "livre", clientMsg)
|
||||
}
|
||||
|
||||
if err := database.IncrementClientCommandCount(clientUsername); err != nil {
|
||||
log.Printf("⚠️ Erreur compteur commandes: %v", err)
|
||||
}
|
||||
|
||||
if err := database.AddClientPointsByCategory(clientUsername, 10, ""); err != nil {
|
||||
log.Printf("⚠️ Erreur ajout points: %v", err)
|
||||
}
|
||||
|
||||
if livreurAssign != "" {
|
||||
err := database.CompleteDeliveryAndProcessNext(livreurAssign, commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur optimisation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("VALIDATION FORCÉE par admin %s - Raison: %s", adminUsername.(string), req.Reason)
|
||||
database.AddCommandLog(commandID, "livre", message, adminUsername.(string))
|
||||
|
||||
log.Printf("🔴 Commande %d validée de force par %s - Raison: %s", commandID, adminUsername.(string), req.Reason)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Livraison validée de force (sans vérification GPS)",
|
||||
"command_id": commandID,
|
||||
"validation_type": "forced",
|
||||
"reason": req.Reason,
|
||||
"validated_by": adminUsername.(string),
|
||||
"new_status": "livre",
|
||||
"points_awarded": 10,
|
||||
"queue_optimized": livreurAssign != "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// ============================================
|
||||
// handlers/cancel_command_handler.go
|
||||
// ANNULATION DE COMMANDES AVEC SANCTIONS ÉVOLUTIVES
|
||||
// VERSION SÉCURISÉE - FIX ETA CHECK
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -195,6 +201,7 @@ func CancelCommandByClient(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ AUTRES ERREURS
|
||||
switch err.Error() {
|
||||
case "commande non trouvée":
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
@@ -211,6 +218,9 @@ func CancelCommandByClient(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SUCCÈS
|
||||
// ============================================
|
||||
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
|
||||
|
||||
response := gin.H{
|
||||
@@ -232,6 +242,10 @@ func CancelCommandByClient(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HISTORIQUE DES ANNULATIONS - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func GetMyCancellationHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -252,12 +266,9 @@ func GetMyCancellationHistory(c *gin.Context) {
|
||||
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
|
||||
var totalPenalty int
|
||||
penaltyQuery := `SELECT COALESCE(amende, 0) FROM clients WHERE username = $1`
|
||||
database.QueryRow(penaltyQuery).Scan(&totalPenalty)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
@@ -311,6 +322,7 @@ func GetAllCancelledOrders(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles)
|
||||
var enrichedOrders []map[string]any
|
||||
for _, order := range cancelledOrders {
|
||||
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
|
||||
|
||||
@@ -109,26 +109,6 @@ func UpdateCategory(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -123,7 +123,6 @@ func GetMyCommandsWithTracking(c *gin.Context) {
|
||||
"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,
|
||||
@@ -209,7 +208,7 @@ func buildTimeline(logs []map[string]any) []gin.H {
|
||||
for _, logEntry := range logs {
|
||||
status, _ := logEntry["status"].(string)
|
||||
message, _ := logEntry["message"].(string)
|
||||
createdAt := logEntry["created_at"]
|
||||
createdAt, _ := logEntry["created_at"]
|
||||
|
||||
timeline = append(timeline, gin.H{
|
||||
"status": status,
|
||||
|
||||
@@ -3,10 +3,8 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -73,47 +71,8 @@ func validateAddress(address string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateCommandDestinationCoords regéocode l'adresse et met à jour
|
||||
// dest_latitude/dest_longitude après tout changement d'adresse de livraison.
|
||||
// Sans cet appel, ces coordonnées restent celles de l'ANCIENNE adresse
|
||||
// (géocodées une seule fois à l'assignation) : la vérification GPS de
|
||||
// handlers/deleviry.go compare alors la position réelle du livreur à un point
|
||||
// périmé et peut refuser à tort une validation "trop loin de la destination"
|
||||
// alors que le livreur est bien arrivé à la nouvelle adresse. En cas d'échec
|
||||
// de géocodage, on réinitialise les coordonnées plutôt que de laisser
|
||||
// l'ancienne valeur périmée : le contrôle GPS est alors ignoré (comportement
|
||||
// déjà prévu quand dest_latitude/dest_longitude sont absentes) au lieu de
|
||||
// bloquer sur un point qui ne correspond plus à l'adresse réelle.
|
||||
func updateCommandDestinationCoords(database *db.Database, geoService *services.GeoService, commandID int, address string) {
|
||||
if geoService == nil || strings.TrimSpace(address) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err != nil || location == nil {
|
||||
log.Printf("⚠️ [ADDR_GEOCODE] Échec géocodage cmd %d (%q): %v — coordonnées de destination réinitialisées", commandID, address, err)
|
||||
if err := database.GDB.Exec(
|
||||
`UPDATE commandes SET dest_latitude = NULL, dest_longitude = NULL WHERE id = ?`,
|
||||
commandID,
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ [ADDR_GEOCODE] Erreur reset coordonnées cmd %d: %v", commandID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.GDB.Exec(
|
||||
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
|
||||
location.Latitude, location.Longitude, commandID,
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ [ADDR_GEOCODE] Erreur mise à jour coordonnées cmd %d: %v", commandID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [ADDR_GEOCODE] Coordonnées de destination mises à jour pour cmd %d", commandID)
|
||||
}
|
||||
|
||||
func UpdateCommandAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if !utils.CheckRoleAdmin(c, userRole) {
|
||||
@@ -177,8 +136,6 @@ func UpdateCommandAddress(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
|
||||
|
||||
database.AddCommandLog(commandID, "address_updated",
|
||||
fmt.Sprintf("Adresse mise à jour par admin %s", adminUsername),
|
||||
adminUsername)
|
||||
@@ -261,7 +218,6 @@ func ProposeAddressChange(c *gin.Context) {
|
||||
// POST /api/v1/commands/:id/address/respond
|
||||
func RespondToAddressProposal(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if !utils.CheckRoleClient(c, userRole) {
|
||||
@@ -288,25 +244,11 @@ func RespondToAddressProposal(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// La colonne proposed_address est vidée par RespondToAddressProposal dès
|
||||
// qu'elle est traitée : on la lit avant l'appel pour pouvoir regéocoder la
|
||||
// nouvelle adresse en cas d'acceptation.
|
||||
var proposedAddress string
|
||||
if req.Accepted {
|
||||
if command, err := database.GetCommandByID(commandID); err == nil {
|
||||
proposedAddress, _ = command["proposed_address"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil {
|
||||
utils.ServerErr(c, "Impossible de traiter la réponse", err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Accepted && proposedAddress != "" {
|
||||
updateCommandDestinationCoords(database, geoService, commandID, proposedAddress)
|
||||
}
|
||||
|
||||
action := "refusée"
|
||||
if req.Accepted {
|
||||
action = "acceptée"
|
||||
@@ -319,64 +261,6 @@ func RespondToAddressProposal(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de sa
|
||||
// propre commande (ex: suite à un échec de géocodage bloquant l'assignation
|
||||
// auto). Refusé si la commande est déjà en_route ou terminée (voir requête
|
||||
// SQL dans db.UpdateOwnCommandAddress).
|
||||
func UpdateOwnCommandAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if !utils.CheckRoleClient(c, userRole) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
clientUsername, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
rateLimitKey := fmt.Sprintf("update_own_addr:%s", clientUsername)
|
||||
if !checkRateLimit(rateLimitKey) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Trop de requêtes, réessayez plus tard"})
|
||||
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 {
|
||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if !geoService.IsValidAddress(req.DeliveryAddress) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe ou le code postal"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateOwnCommandAddress(commandID, clientUsername, req.DeliveryAddress); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
|
||||
|
||||
log.Printf("✅ [UPD_OWN_ADDR] Commande %d mise à jour par %s", commandID, clientUsername)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Adresse mise à jour",
|
||||
})
|
||||
}
|
||||
|
||||
func ExportApprovedCommandsCSV(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -596,6 +480,10 @@ func StaffApproveDelivery(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// APPROBATION PAR ADMIN
|
||||
// ============================================
|
||||
|
||||
func ValidateDelivery(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -670,7 +558,7 @@ func ValidateDelivery(c *gin.Context) {
|
||||
|
||||
currentStatus, _ := command["status"].(string)
|
||||
|
||||
validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
|
||||
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
|
||||
if !slices.Contains(validStatuses, currentStatus) {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
@@ -707,6 +595,10 @@ func ValidateDelivery(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GESTION ADMIN
|
||||
// ============================================
|
||||
|
||||
// GetAvailableDeliveryPersons récupère les livreurs disponibles
|
||||
// GET /api/v1/admin/delivery-persons/available
|
||||
func GetAvailableDeliveryPersons(c *gin.Context) {
|
||||
@@ -742,7 +634,6 @@ func GetAvailableDeliveryPersons(c *gin.Context) {
|
||||
// Cabine: POST /api/v1/cabine/commands/:id/assign (body: {"livreur_username": "..."})
|
||||
func AssignDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin ou Cabine
|
||||
role := c.GetString("role")
|
||||
@@ -792,39 +683,6 @@ func AssignDeliveryPerson(c *gin.Context) {
|
||||
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername),
|
||||
staffUsername.(string))
|
||||
|
||||
// Géocodage async : stocker les coords si absentes
|
||||
go func() {
|
||||
cmd, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dLat, _ := cmd["dest_latitude"].(float64)
|
||||
dLon, _ := cmd["dest_longitude"].(float64)
|
||||
if dLat != 0 && dLon != 0 {
|
||||
return // coords déjà présentes
|
||||
}
|
||||
adresse, _ := cmd["adresse"].(string)
|
||||
if adresse == "" {
|
||||
return
|
||||
}
|
||||
location, err := geoService.GeocodeAddress(adresse)
|
||||
if err != nil || location == nil {
|
||||
log.Printf("⚠️ [ASSIGN] Géocodage échoué pour cmd %d: %v", commandID, err)
|
||||
return
|
||||
}
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": location.Latitude,
|
||||
"lon": location.Longitude,
|
||||
})
|
||||
destKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
db.Redis.Set(db.RedisCtx, destKey, coordsJSON, 4*time.Hour)
|
||||
database.GDB.Exec(
|
||||
"UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
|
||||
location.Latitude, location.Longitude, commandID,
|
||||
)
|
||||
log.Printf("📍 [ASSIGN] Coords stockées pour cmd %d: (%.6f, %.6f)", commandID, location.Latitude, location.Longitude)
|
||||
}()
|
||||
|
||||
log.Printf("✅ [ASSIGN] Commande %d assignée à %s", commandID, livreurUsername)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -920,6 +778,13 @@ func GetClientCommandsHistory(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NOTIFICATIONS CLIENT
|
||||
// ============================================
|
||||
|
||||
// NotifyClientToDescend envoie une notification push au client pour descendre récupérer sa commande
|
||||
// POST /api/v2/admin/protected/orders/:id/notify-client
|
||||
// POST /api/v1/cabine/commands/:id/notify-client
|
||||
func NotifyClientToDescend(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -1254,12 +1119,7 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "cancelled" {
|
||||
if err := database.CancelCommandByAdminAtomic(commandID); err != nil {
|
||||
utils.ServerErr(c, "Impossible d'annuler la commande", err)
|
||||
return
|
||||
}
|
||||
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -15,9 +15,8 @@ import (
|
||||
// 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 {
|
||||
np, ok := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
if !ok || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -35,32 +33,19 @@ func GetMyDeliveries(c *gin.Context) {
|
||||
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération",
|
||||
"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]
|
||||
items, _ := database.GetCommandItems(commandID)
|
||||
|
||||
// Client info SANS téléphone
|
||||
clientUsername, _ := cmd["username"].(string)
|
||||
client := allClients[clientUsername]
|
||||
client, _ := database.GetClientByUsername(clientUsername)
|
||||
|
||||
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
||||
if client != nil {
|
||||
@@ -73,26 +58,24 @@ func GetMyDeliveries(c *gin.Context) {
|
||||
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"],
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
"id": cmd["id"],
|
||||
"status": cmd["status"],
|
||||
"adresse": cmd["adresse"],
|
||||
"total_prix": cmd["total_prix"],
|
||||
"created_at": cmd["created_at"],
|
||||
"client_info": clientInfo,
|
||||
"items": itemsSummary,
|
||||
"items_count": len(items),
|
||||
"eta": etaData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,10 +138,9 @@ func GetDeliveryDetails(c *gin.Context) {
|
||||
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"],
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +188,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -258,53 +240,25 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
|
||||
log.Printf("📍 [GPS] Distance: %.2f m", distance)
|
||||
|
||||
if distance > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Vous êtes trop loin de la destination",
|
||||
"current_distance": fmt.Sprintf("%.2f", distance),
|
||||
"unit": "meters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
// Mettre à jour le statut
|
||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -346,53 +300,28 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
}
|
||||
|
||||
if destLat != 0 && destLon != 0 {
|
||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
||||
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, 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)
|
||||
if err := database.SetCommandETA(commandID, etaMinutes); err != nil {
|
||||
log.Printf("⚠️ [STATUS_LIVREUR] Erreur définition ETA: %v", err)
|
||||
} 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))
|
||||
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)
|
||||
}
|
||||
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)
|
||||
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut")
|
||||
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)
|
||||
database.SetCommandETA(commandID, etaMinutes)
|
||||
}
|
||||
|
||||
// Mettre à jour le statut du livreur en "delivering"
|
||||
@@ -463,7 +392,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "cancelled":
|
||||
// Transition + remboursement stock déjà effectués atomiquement plus haut.
|
||||
// Annulation par le livreur - Nettoyer la queue
|
||||
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
@@ -543,83 +472,3 @@ func ReportDeliveryIssue(c *gin.Context) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
func GetDeliveryPersonDetails(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
|
||||
@@ -39,7 +39,7 @@ func GetDeliveryPersonDetails(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Livreur non trouvé: %v", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Livreur non trouvé",
|
||||
"error": "Livreur non trouvé",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -62,9 +62,9 @@ func GetDeliveryPersonDetails(c *gin.Context) {
|
||||
|
||||
// Utiliser la fonction GPS existante
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
var locationInfo map[string]any
|
||||
var locationInfo map[string]interface{}
|
||||
if err == nil {
|
||||
locationInfo = map[string]any{
|
||||
locationInfo = map[string]interface{}{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
}
|
||||
@@ -116,14 +116,22 @@ func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut requis",
|
||||
"error": "Statut requis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Valider le statut
|
||||
validStatuses := []string{"available", "busy", "offline"}
|
||||
isValid := false
|
||||
for _, vs := range validStatuses {
|
||||
if req.Status == vs {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(validStatuses, req.Status) {
|
||||
if !isValid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut invalide",
|
||||
"valid_statuses": validStatuses,
|
||||
@@ -152,7 +160,7 @@ func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour statut",
|
||||
"error": "Erreur mise à jour statut",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -313,7 +321,7 @@ func GetDeliveryPersonHistory(c *gin.Context) {
|
||||
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",
|
||||
"error": "Erreur récupération historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -360,7 +368,7 @@ func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -407,7 +415,7 @@ func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour position",
|
||||
"error": "Erreur mise à jour position",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -429,6 +437,12 @@ func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🗑️ REMOVE COMMAND FROM QUEUE
|
||||
// ============================================
|
||||
|
||||
// RemoveCommandFromQueue retire une commande de la queue d'un livreur
|
||||
// DELETE /api/v2/admin/protected/delivery-persons/:username/queue/:command_id
|
||||
func RemoveCommandFromQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -460,6 +474,9 @@ func RemoveCommandFromQueue(c *gin.Context) {
|
||||
|
||||
log.Printf("🗑️ [REMOVE_FROM_QUEUE] Suppression: cmd %d de la queue de %s", commandID, username)
|
||||
|
||||
// ============================================
|
||||
// Vérifier que le livreur existe
|
||||
// ============================================
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
|
||||
@@ -474,6 +491,9 @@ func RemoveCommandFromQueue(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Vérifier que la commande existe
|
||||
// ============================================
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
|
||||
@@ -481,15 +501,19 @@ func RemoveCommandFromQueue(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Retirer de la queue
|
||||
// ============================================
|
||||
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",
|
||||
"error": "Erreur suppression de la queue",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Optionnel: Réassigner la commande en "pending"
|
||||
currentStatus, _ := command["status"].(string)
|
||||
if currentStatus == "assigned" || currentStatus == "en_route" {
|
||||
err = database.UpdateCommandStatus(commandID, "pending")
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// ============================================
|
||||
// handlers/eta_handler_corrected.go
|
||||
// CORRECTION: ETA visible UNIQUEMENT après en_route
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -13,42 +18,6 @@ import (
|
||||
"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)
|
||||
@@ -85,6 +54,7 @@ func GetOrderETA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 4️⃣ VÉRIFIER LES DROITS D'ACCÈS
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
userRole := c.GetString("role")
|
||||
|
||||
@@ -111,8 +81,10 @@ func GetOrderETA(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE
|
||||
cmdStatus, _ := command["status"].(string)
|
||||
|
||||
// ✅ CORRECTION: Vérifier si commande terminée
|
||||
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{
|
||||
@@ -126,30 +98,21 @@ func GetOrderETA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if cmdStatus == "pending" || cmdStatus == "assigned" {
|
||||
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
|
||||
// Pour pending: aucune estimation disponible
|
||||
if cmdStatus == "pending" {
|
||||
log.Printf("⏳ [ETA] Commande en attente d'assignation - pas d'ETA")
|
||||
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",
|
||||
"message": "En attente d'assignation d'un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Pour assigned/en_route/arrived: calcul ETA réel via position du livreur
|
||||
|
||||
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
||||
|
||||
@@ -190,8 +153,10 @@ func GetOrderETA(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 7️⃣ Pas de cache valide - Recalculer l'ETA
|
||||
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
|
||||
|
||||
// Récupérer coordonnées destination
|
||||
var destLat, destLon float64
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
@@ -218,8 +183,11 @@ func GetOrderETA(c *gin.Context) {
|
||||
}
|
||||
|
||||
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))
|
||||
log.Printf("❌ [ETA] Coordonnées destination manquantes")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "Coordonnées de destination manquantes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -227,30 +195,26 @@ func GetOrderETA(c *gin.Context) {
|
||||
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",
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "Aucun livreur assigné à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
livreurLocation, err := geoService.GetDeliveryPersonLocation(livreurAssign)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ETA] Position livreur introuvable: %s", livreurAssign)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Position du livreur non 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)
|
||||
}
|
||||
|
||||
// Calculer ETA avec TomTom
|
||||
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
|
||||
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
|
||||
|
||||
@@ -261,10 +225,11 @@ func GetOrderETA(c *gin.Context) {
|
||||
etaMinutes = services.CalculateETA(distanceKm)
|
||||
}
|
||||
|
||||
// Sauvegarder en cache
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
|
||||
|
||||
etaCache := map[string]any{
|
||||
etaCache := map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// ============================================
|
||||
// handlers/geo_handlers.go - VERSION CORRIGÉE COMPLÈTE
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -13,6 +17,10 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// GÉOCODAGE D'ADRESSES
|
||||
// ============================================
|
||||
|
||||
func GeocodeAddress(c *gin.Context) {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
@@ -26,40 +34,19 @@ func GeocodeAddress(c *gin.Context) {
|
||||
|
||||
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,
|
||||
})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Impossible de géocoder cette adresse"})
|
||||
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,
|
||||
"success": true,
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -169,6 +156,10 @@ func FindNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE
|
||||
// ============================================
|
||||
|
||||
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
|
||||
func GetAllDeliveryDistances(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
@@ -254,6 +245,9 @@ func GetAllDeliveryDistances(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES
|
||||
// ============================================
|
||||
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
@@ -311,6 +305,9 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", address, location.Latitude, location.Longitude)
|
||||
|
||||
// ============================================
|
||||
// 🔹 SAUVEGARDER LES COORDONNÉES DANS LE CACHE REDIS
|
||||
// ============================================
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": location.Latitude,
|
||||
@@ -340,9 +337,12 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
|
||||
|
||||
// Récupérer les livreurs actifs avec capacité disponible
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
|
||||
// Si aucun livreur avec capacité disponible
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
// Cas 1: Un seul livreur actif -> pas de limite
|
||||
if activeCount == 1 {
|
||||
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
|
||||
if err != nil {
|
||||
@@ -361,6 +361,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Passer les coordonnées à la fonction d'assignation
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -375,6 +376,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement sans accès aux clés
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée au seul livreur actif (sans limite)",
|
||||
@@ -387,7 +389,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
"single_driver": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData,
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
@@ -398,11 +400,13 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée
|
||||
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
|
||||
|
||||
if allAtCapacity && numActive > 1 {
|
||||
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
|
||||
|
||||
// Trouver le livreur le moins chargé (même s'il dépasse 10)
|
||||
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -410,6 +414,8 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculer le temps de trajet avec TomTom
|
||||
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -417,6 +423,8 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Assigner de force avec coordonnées
|
||||
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -431,6 +439,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
|
||||
@@ -444,7 +453,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
"over_capacity": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData,
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
@@ -455,6 +464,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cas 3: Erreur générique
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur actif avec capacité disponible",
|
||||
"active_count": activeCount,
|
||||
@@ -463,11 +473,13 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cas normal: Au moins un livreur avec capacité disponible
|
||||
usernames := make([]string, len(activeLivreurs))
|
||||
for i, livreur := range activeLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le livreur le plus proche (calcul rapide)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -476,6 +488,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Recalculer l'ETA avec TomTom pour plus de précision
|
||||
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
||||
if err != nil {
|
||||
// Fallback sur le calcul initial
|
||||
@@ -486,6 +499,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
|
||||
|
||||
// ✅ Assigner à la queue du livreur avec coordonnées
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -500,6 +514,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée à la queue du livreur",
|
||||
@@ -512,7 +527,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
"single_driver": activeCount == 1,
|
||||
"traffic_aware": err == nil,
|
||||
},
|
||||
"eta": etaData,
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
@@ -522,6 +537,12 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ASSIGNATION EN MASSE (TOUTES LES COMMANDES PENDING)
|
||||
// ============================================
|
||||
|
||||
// AutoAssignAllPendingCommands assigne toutes les commandes en attente
|
||||
// POST /api/v2/admin/protected/commands/auto-assign-all
|
||||
func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
@@ -532,6 +553,7 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer toutes les commandes pending
|
||||
commands, err := database.GetAllCommands("pending", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -557,6 +579,7 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
for _, cmd := range commands {
|
||||
commandID, ok := cmd["id"].(int)
|
||||
if !ok {
|
||||
// Essayer avec float64
|
||||
if idFloat, ok := cmd["id"].(float64); ok {
|
||||
commandID = int(idFloat)
|
||||
} else {
|
||||
@@ -564,6 +587,7 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer l'adresse
|
||||
address, ok := cmd["adresse"].(string)
|
||||
if !ok || address == "" || address == "Adresse non spécifiée" {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -573,6 +597,7 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Géocoder l'adresse
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -587,6 +612,7 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
Longitude: location.Longitude,
|
||||
}
|
||||
|
||||
// Récupérer les livreurs actifs
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -601,6 +627,7 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le livreur le plus proche (version rapide pour assignation masse)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -610,6 +637,7 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pour l'assignation en masse, on utilise le calcul rapide
|
||||
travelTime := nearest.EstimatedTime
|
||||
distance := nearest.Distance
|
||||
|
||||
@@ -623,10 +651,13 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mettre à jour le statut du livreur
|
||||
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
|
||||
// Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
var totalETA, waitTime any
|
||||
var totalETA, waitTime interface{}
|
||||
totalETA = "N/A"
|
||||
waitTime = "N/A"
|
||||
|
||||
@@ -651,6 +682,7 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
|
||||
}
|
||||
|
||||
// Récupérer l'overview des queues
|
||||
queuesOverview, _ := database.GetAllQueuesOverview()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -666,7 +698,12 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RÉCUPÉRER L'ÉTAT DES QUEUES DES LIVREURS
|
||||
// ============================================
|
||||
|
||||
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
|
||||
// GET /api/v2/admin/protected/delivery/queues
|
||||
func GetAllDeliveryQueues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -684,6 +721,7 @@ func GetAllDeliveryQueues(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les détails de chaque livreur
|
||||
var deliverymenDetails []gin.H
|
||||
|
||||
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
|
||||
@@ -694,7 +732,7 @@ func GetAllDeliveryQueues(c *gin.Context) {
|
||||
|
||||
// Récupérer le statut
|
||||
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
|
||||
var status map[string]any
|
||||
var status map[string]interface{}
|
||||
if statusData != "" {
|
||||
json.Unmarshal([]byte(statusData), &status)
|
||||
}
|
||||
@@ -713,6 +751,12 @@ func GetAllDeliveryQueues(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RÉCUPÉRER LA QUEUE D'UN LIVREUR SPÉCIFIQUE
|
||||
// ============================================
|
||||
|
||||
// GetDeliverymanQueue retourne la queue d'un livreur spécifique
|
||||
// GET /api/v2/admin/protected/delivery/:username/queue
|
||||
func GetDeliverymanQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
|
||||
@@ -13,13 +13,16 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetDeliveryPersonMapLinks génère les liens de cartes pour visualiser la position d'un livreur
|
||||
// GET /api/v2/admin/protected/delivery-persons/:username/map-links
|
||||
func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Vérification du rôle admin
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
@@ -34,6 +37,7 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
|
||||
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
|
||||
|
||||
// Récupérer la position GPS du livreur
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
|
||||
@@ -45,6 +49,7 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validation des coordonnées
|
||||
if lat == 0 && lon == 0 {
|
||||
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -55,6 +60,7 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les liens de cartes
|
||||
mapLinks := database.GenerateMapLinks(lat, lon, username)
|
||||
|
||||
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
|
||||
@@ -73,6 +79,58 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommandNavigationLinks génère les liens de navigation pour une commande
|
||||
// GET /api/v2/admin/protected/commands/:id/navigation-links
|
||||
func GetCommandNavigationLinks(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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Vérifier qu'un livreur est assigné
|
||||
livreurAssign, ok := command["livreur_assign"].(string)
|
||||
if !ok || livreurAssign == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur assigné à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les liens de navigation
|
||||
links, err := database.GenerateMapLinksForCommand(commandID, livreurAssign)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur génération des liens",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"deliveryman": livreurAssign,
|
||||
"navigation_links": links,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté
|
||||
// GET /api/v1/livreur/deliveries/:id/nav-link
|
||||
func GetLivreurNavLink(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.GetString("username")
|
||||
@@ -95,6 +153,7 @@ func GetLivreurNavLink(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Priorité : coordonnées GPS de la destination
|
||||
var wazeLink string
|
||||
destLat, hasLat := command["dest_latitude"].(float64)
|
||||
destLon, hasLon := command["dest_longitude"].(float64)
|
||||
|
||||
@@ -1,21 +1,107 @@
|
||||
// ============================================
|
||||
// handlers/history_handlers.go
|
||||
// ============================================
|
||||
// Gestion de l'historique des commandes terminées
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMyCompletedOrders récupère l'historique des commandes terminées du client
|
||||
// GET /api/v1/my-commands/history
|
||||
// ✅ Authentification requise (ClientMiddleware)
|
||||
// ✅ Retourne uniquement les commandes avec status = "approved"
|
||||
func GetMyCompletedOrders(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [HISTORY] Utilisateur non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authentification requise",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
log.Printf("📚 [HISTORY] Récupération historique pour: %s", usernameStr)
|
||||
|
||||
// ✅ Récupérer les commandes terminées (approved)
|
||||
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [HISTORY] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [HISTORY] %d commandes terminées trouvées", len(commands))
|
||||
|
||||
// ✅ Récupérer les infos client pour statistiques
|
||||
client, err := database.GetClientByUsername(usernameStr)
|
||||
|
||||
// ✅ Récupérer les noms et clés des pools de points
|
||||
poolNames := []string{"Pool 1", "Pool 2"}
|
||||
var poolKeys []string
|
||||
if settings, sErr := database.GetSettings(); sErr == nil && len(settings.PointsPools) > 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"commands": commands,
|
||||
"count": len(commands),
|
||||
}
|
||||
|
||||
if err == nil && client != nil {
|
||||
// Construire le tableau depuis points_extra[poolKey] pour tous les pools (stockage dynamique)
|
||||
poolPoints := make([]int, len(poolKeys))
|
||||
for i, key := range poolKeys {
|
||||
if key != "" {
|
||||
poolPoints[i] = client.PointsExtra[key]
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📊 [HISTORY] pool_names=%v pool_keys=%v pool_points=%v extra=%v",
|
||||
poolNames, poolKeys, poolPoints, client.PointsExtra)
|
||||
|
||||
response["client_stats"] = gin.H{
|
||||
"username": client.Username,
|
||||
"total_commands": client.Command,
|
||||
"points_extra": client.PointsExtra,
|
||||
"pool_points": poolPoints,
|
||||
"pool_names": poolNames,
|
||||
"penalties": client.Amende,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
|
||||
// GET /api/v1/my-commands/history/detailed
|
||||
// ✅ Authentification requise (ClientMiddleware)
|
||||
// ✅ Retourne les commandes approved avec tous les items
|
||||
func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
|
||||
@@ -28,16 +114,18 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
usernameStr := username.(string)
|
||||
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
|
||||
|
||||
// ✅ Récupérer les commandes terminées
|
||||
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",
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var enrichedCommands []map[string]any
|
||||
// ✅ Enrichir chaque commande avec ses items
|
||||
var enrichedCommands []map[string]interface{}
|
||||
|
||||
for _, command := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
|
||||
@@ -45,15 +133,18 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Récupérer les items de cette commande
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
|
||||
items = []map[string]any{}
|
||||
items = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Ajouter les items à la commande
|
||||
enrichedCommand := make(map[string]any)
|
||||
maps.Copy(enrichedCommand, command)
|
||||
enrichedCommand := make(map[string]interface{})
|
||||
for k, v := range command {
|
||||
enrichedCommand[k] = v
|
||||
}
|
||||
enrichedCommand["items"] = items
|
||||
enrichedCommand["items_count"] = len(items)
|
||||
|
||||
@@ -62,6 +153,7 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
|
||||
|
||||
// ✅ Récupérer les infos client
|
||||
client, err := database.GetClientByUsername(usernameStr)
|
||||
|
||||
response := gin.H{
|
||||
@@ -85,9 +177,14 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetOrderHistory récupère l'historique d'une commande spécifique avec logs
|
||||
// GET /api/v1/commands/:id/history
|
||||
// ✅ Authentification requise
|
||||
// ✅ Vérifie que la commande appartient au client
|
||||
func GetOrderHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
|
||||
@@ -99,6 +196,7 @@ func GetOrderHistory(c *gin.Context) {
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
// Récupérer l'ID de la commande
|
||||
var commandID int
|
||||
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -109,6 +207,7 @@ func GetOrderHistory(c *gin.Context) {
|
||||
|
||||
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
|
||||
|
||||
// ✅ Vérifier que la commande existe
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
|
||||
@@ -118,6 +217,7 @@ func GetOrderHistory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Vérifier que la commande appartient au client
|
||||
cmdUsername, ok := command["username"].(string)
|
||||
if !ok || cmdUsername != usernameStr {
|
||||
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
|
||||
@@ -127,16 +227,18 @@ func GetOrderHistory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Récupérer les logs de la commande
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
|
||||
logs = []map[string]any{}
|
||||
logs = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
// ✅ Récupérer les items
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
|
||||
items = []map[string]any{}
|
||||
items = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), 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),
|
||||
})
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func GetClientNotifications(c *gin.Context) {
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
// Récupérer toutes les notifications (max 50)
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||
@@ -127,7 +128,7 @@ func MarkLivreurNotificationsRead(c *gin.Context) {
|
||||
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]any
|
||||
var n map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -170,7 +171,7 @@ func MarkNotificationsRead(c *gin.Context) {
|
||||
// Réécrire chaque notification avec read=true
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]any
|
||||
var n map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// ============================================
|
||||
// handlers/basket_handlers_CORRIGES.go
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -8,8 +12,6 @@ import (
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -22,6 +24,9 @@ type BasketsRequest struct {
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ SÉCURISÉ: AddProductsBasket
|
||||
// ============================================
|
||||
// POST /api/v1/panier/add
|
||||
func AddProductsBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
@@ -43,38 +48,41 @@ func AddProductsBasket(c *gin.Context) {
|
||||
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"})
|
||||
// Si product_id fourni par le mobile, on l'utilise directement (plus fiable)
|
||||
if req.ProductID > 0 || req.NameProduct == "" || req.Category == "" {
|
||||
stock, err := database.GetProductStockByID(req.ProductID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADD_PANIER] Produit %d non trouvé: %v", req.ProductID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "prix introuvable") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun prix configuré pour ce produit"})
|
||||
if stock < req.Quantity {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock})
|
||||
return
|
||||
}
|
||||
utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
|
||||
if err := database.DecrementProductStockByID(req.ProductID, req.Quantity); err != nil {
|
||||
log.Printf("❌ [ADD_PANIER] Erreur décrement stock product_id=%d: %v", req.ProductID, err)
|
||||
utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
|
||||
return
|
||||
}
|
||||
panier, err := database.AddProductInBasketByID(req.Username, req.ProductID, req.Quantity)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADD_PANIER] Erreur ajout product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
|
||||
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})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Produit ajouté au panier avec succès",
|
||||
"panier": panier,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ============================================
|
||||
// GET /api/v1/panier/:username
|
||||
// Récupère le panier du client authentifié
|
||||
func GetAllBaskets(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
@@ -87,6 +95,7 @@ func GetAllBaskets(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
|
||||
@@ -96,6 +105,7 @@ func GetAllBaskets(c *gin.Context) {
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
// ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session
|
||||
if username != authUsernameStr {
|
||||
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
|
||||
log.Printf(" Username du JWT: %s", authUsernameStr)
|
||||
@@ -106,8 +116,10 @@ func GetAllBaskets(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT
|
||||
username = authUsernameStr
|
||||
|
||||
// ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT
|
||||
_, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
|
||||
@@ -123,7 +135,7 @@ func GetAllBaskets(c *gin.Context) {
|
||||
|
||||
var totalAmount float64
|
||||
for _, item := range baskets {
|
||||
totalAmount += item.Price
|
||||
totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
|
||||
@@ -137,6 +149,11 @@ func GetAllBaskets(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ SÉCURISÉ: DeleteProductFromBasket
|
||||
// ============================================
|
||||
// DELETE /api/v1/panier/remove
|
||||
// Supprime un produit du panier
|
||||
func DeleteProductFromBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -149,6 +166,7 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
|
||||
@@ -160,6 +178,7 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
|
||||
|
||||
// ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client
|
||||
itemUsername, err := database.GetBasketItemOwner(req.ID)
|
||||
|
||||
if err != nil {
|
||||
@@ -178,6 +197,7 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Supprimer l'article
|
||||
err = database.DeleteProductFromBasket(req.ID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la suppression", err)
|
||||
@@ -186,15 +206,22 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
|
||||
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,
|
||||
"success": true,
|
||||
"message": "Produit supprimé du panier avec succès",
|
||||
"item_id": req.ID,
|
||||
"stock_released": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ SÉCURISÉ: ClearBasket
|
||||
// ============================================
|
||||
// DELETE /api/v1/panier/clear
|
||||
// Vide le panier du client
|
||||
func ClearBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
|
||||
@@ -223,8 +250,9 @@ func ClearBasket(c *gin.Context) {
|
||||
|
||||
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",
|
||||
"success": true,
|
||||
"message": "Panier vidé avec succès",
|
||||
"stock_released": len(baskets),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,19 +268,11 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
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"`
|
||||
PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto"
|
||||
PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto)
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
||||
@@ -266,6 +286,7 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
req.DeliveryAddress = cmd.DeliveryAddress
|
||||
|
||||
// Vérifier que le client a lié son compte Telegram (seulement si les notifications sont activées)
|
||||
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"})
|
||||
@@ -275,6 +296,9 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
|
||||
|
||||
// ============================================
|
||||
// 1️⃣ Vérifier que le panier n'est pas vide
|
||||
// ============================================
|
||||
items, err := database.GetBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer le panier", err)
|
||||
@@ -289,6 +313,9 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
||||
|
||||
// ============================================
|
||||
// 1️⃣b Vérifier le minimum de commande selon la zone
|
||||
// ============================================
|
||||
var cartTotal float64
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok {
|
||||
@@ -296,20 +323,10 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// Récupérer les paramètres globaux (zones + parrainage)
|
||||
appSettings, _ := database.GetSettings()
|
||||
|
||||
// Récupérer le solde parrainage disponible (seulement si le système est activé)
|
||||
var referralBalance float64
|
||||
if req.UseReferralBalance && appSettings.ReferralEnabled {
|
||||
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
|
||||
@@ -343,6 +360,8 @@ func ValidateBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone.
|
||||
// Ex : zone 50€, crédit 50€ → panier doit être >= 100€
|
||||
var referralUsed float64
|
||||
if req.UseReferralBalance && referralBalance > 0 {
|
||||
effectivePayment := cartTotal - referralBalance
|
||||
@@ -365,6 +384,9 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed)
|
||||
|
||||
// ============================================
|
||||
// 2️⃣ Débiter le parrainage AVANT la commande (évite double-spend)
|
||||
// ============================================
|
||||
if referralUsed > 0 {
|
||||
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
|
||||
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err)
|
||||
@@ -374,25 +396,11 @@ func ValidateBasket(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
// Vérification option crypto
|
||||
isCrypto := req.PaymentMethod == "crypto"
|
||||
if isCrypto {
|
||||
npRaw, npExists := c.Get("nowpayments")
|
||||
np, npOk := npRaw.(*services.NowPaymentsClient)
|
||||
if !npExists || !npOk || np == nil {
|
||||
np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
if !npOk || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
|
||||
return
|
||||
}
|
||||
@@ -408,10 +416,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
_ = 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
|
||||
}
|
||||
@@ -425,6 +429,9 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
|
||||
|
||||
// ============================================
|
||||
// PAIEMENT CRYPTO - créer le paiement NowPayments
|
||||
// ============================================
|
||||
if isCrypto {
|
||||
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
||||
@@ -437,6 +444,7 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
payResp, err := np.CreatePayment(payReq)
|
||||
if err != nil {
|
||||
// Annuler la commande et restaurer le panier / parrainage
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
@@ -446,21 +454,14 @@ func ValidateBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Passer la commande en 'pending_payment' (attente confirmation)
|
||||
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
|
||||
}
|
||||
_, _ = database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt)
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
@@ -478,8 +479,22 @@ func ValidateBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Notifier immédiatement tous les admins et agents cabine
|
||||
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
|
||||
|
||||
// ============================================
|
||||
// 3️⃣ Vider le panier (sans restituer le stock — déjà déduit à l'ajout)
|
||||
// ============================================
|
||||
err = database.ClearBasketOnCheckout(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de vider le panier", err)
|
||||
return
|
||||
}
|
||||
log.Printf("🧹 [CHECKOUT] Panier vidé")
|
||||
|
||||
// ============================================
|
||||
// 4️⃣ Auto-assignation livreur (optionnel)
|
||||
// ============================================
|
||||
var assigned bool
|
||||
var assignInfo gin.H
|
||||
|
||||
@@ -499,6 +514,7 @@ func ValidateBasket(c *gin.Context) {
|
||||
if err == nil {
|
||||
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
|
||||
|
||||
// ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic
|
||||
travelTime, distance, err := services.CalculateETAWithTomTom(
|
||||
nearest.Location,
|
||||
services.Coordinates{
|
||||
@@ -516,6 +532,7 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
|
||||
|
||||
// Assigner la commande au livreur
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(
|
||||
commandID,
|
||||
nearest.Username,
|
||||
@@ -528,10 +545,13 @@ func ValidateBasket(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
|
||||
} else {
|
||||
// Mettre à jour le statut du livreur
|
||||
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
|
||||
}
|
||||
|
||||
// Notifier le livreur de la nouvelle commande
|
||||
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)
|
||||
@@ -539,6 +559,8 @@ func ValidateBasket(c *gin.Context) {
|
||||
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
|
||||
}
|
||||
|
||||
// Notifier le client
|
||||
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)
|
||||
@@ -561,6 +583,9 @@ func ValidateBasket(c *gin.Context) {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5️⃣ Réponse
|
||||
// ============================================
|
||||
newBalance, _ := database.GetClientReferralBalance(usernameStr)
|
||||
resp := gin.H{
|
||||
"success": true,
|
||||
@@ -587,6 +612,7 @@ func ValidateBasket(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// getBaseURL construit l'URL de base depuis la requête en cours
|
||||
func getBaseURL(c *gin.Context) string {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetClientParrainAdmin — POST /api/v2/admin/protected/client/:username/parrain/set (admin)
|
||||
// Assigne un parrain à un client. Le parrain reçoit settings.ReferralAmount sur son solde.
|
||||
func SetClientParrainAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
@@ -26,12 +28,14 @@ func SetClientParrainAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le parrain existe
|
||||
parrain, err := database.GetClientByUsername(req.Parrain)
|
||||
if err != nil || parrain == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le client n'a pas déjà un parrain
|
||||
existing, err := database.GetClientParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification parrain", err)
|
||||
@@ -42,23 +46,25 @@ func SetClientParrainAdmin(c *gin.Context) {
|
||||
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 {
|
||||
if err := database.SetClientParrain(targetUsername, req.Parrain); err != nil {
|
||||
utils.ServerErr(c, "Erreur enregistrement parrain", err)
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount)
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
|
||||
if err := database.CreditClientReferral(req.Parrain, settings.ReferralAmount); err != nil {
|
||||
log.Printf("⚠️ [PARRAIN] Impossible de créditer %s: %v", req.Parrain, err)
|
||||
} else {
|
||||
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, settings.ReferralAmount)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Parrain enregistré",
|
||||
"client": targetUsername,
|
||||
"parrain": req.Parrain,
|
||||
"amount_credited": creditAmount,
|
||||
"amount_credited": settings.ReferralAmount,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,432 +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"
|
||||
}
|
||||
|
||||
// categoryRewardCandidate représente un produit éligible à la récompense pour
|
||||
// une config de catégorie donnée : son type ("free_product" |
|
||||
// "half_price_product") et la quantité configurée pour cette catégorie.
|
||||
type categoryRewardCandidate struct {
|
||||
Category string
|
||||
Type string
|
||||
ProductID int
|
||||
Name string
|
||||
Quantity float64
|
||||
}
|
||||
|
||||
// resolveCategoryRewardCandidates dérive, pour chaque config de catégorie de
|
||||
// la récompense, la liste des produits éligibles — tous ceux du catalogue si
|
||||
// AllProducts, sinon la sélection explicite — avec le type et la quantité
|
||||
// configurés directement dans le bloc catégorie (RewardCategoryConfig).
|
||||
// Il n'existe plus de liste "reward_items" saisie à part : la catégorie est
|
||||
// l'unique source de vérité (type + produits + quantité).
|
||||
func resolveCategoryRewardCandidates(database *db.Database, reward *models.PointsReward) ([]categoryRewardCandidate, error) {
|
||||
candidates := make([]categoryRewardCandidate, 0)
|
||||
if reward == nil {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
catalogCache := make(map[string][]models.Product)
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
rewardType := normalizeRewardCategoryType(cfg.Type)
|
||||
if cfg.AllProducts {
|
||||
products, ok := catalogCache[cfg.Category]
|
||||
if !ok {
|
||||
var err error
|
||||
products, err = database.GetProductsByCategory(cfg.Category)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("produits catégorie %q: %w", cfg.Category, err)
|
||||
}
|
||||
catalogCache[cfg.Category] = products
|
||||
}
|
||||
for _, p := range products {
|
||||
candidates = append(candidates, categoryRewardCandidate{
|
||||
Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity,
|
||||
})
|
||||
}
|
||||
} else if len(cfg.Products) > 0 {
|
||||
ids := make([]int, len(cfg.Products))
|
||||
for i, pq := range cfg.Products {
|
||||
ids[i] = pq.ProductID
|
||||
}
|
||||
names, err := database.GetProductNamesByIDs(ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err)
|
||||
}
|
||||
for _, pq := range cfg.Products {
|
||||
candidates = append(candidates, categoryRewardCandidate{
|
||||
Category: cfg.Category, Type: rewardType, ProductID: pq.ProductID, Name: names[pq.ProductID], Quantity: pq.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// effectiveRewardPrice calcule le prix réellement facturé pour une quantité
|
||||
// donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour
|
||||
// "free_product", 50% du prix catalogue actif (palier ≤ quantity) 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, productID int, quantity float64, rewardType string) (float64, error) {
|
||||
if rewardType != "half_price_product" {
|
||||
return 0, nil
|
||||
}
|
||||
catalogPrice, err := database.GetActiveProductPrice(productID, quantity)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", 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 ConfigProductResponse struct {
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
type EligibleConfigResponse struct {
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
AllProducts bool `json:"all_products"`
|
||||
Products []ConfigProductResponse `json:"products"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
products := make([]ConfigProductResponse, 0, len(cfg.Products))
|
||||
for _, pq := range cfg.Products {
|
||||
name := ""
|
||||
for _, cand := range candidates {
|
||||
if cand.ProductID == pq.ProductID && cand.Category == cfg.Category {
|
||||
name = cand.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
products = append(products, ConfigProductResponse{
|
||||
ProductID: pq.ProductID,
|
||||
ProductName: name,
|
||||
Quantity: pq.Quantity,
|
||||
})
|
||||
}
|
||||
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
||||
Category: cfg.Category,
|
||||
Type: normalizeRewardCategoryType(cfg.Type),
|
||||
AllProducts: cfg.AllProducts,
|
||||
Products: products,
|
||||
Quantity: cfg.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
eligibleRewardItems := make([]RewardItemResponse, 0)
|
||||
for _, cand := range candidates {
|
||||
if !poolCats[cand.Category] {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
|
||||
continue
|
||||
}
|
||||
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
|
||||
ProductID: cand.ProductID,
|
||||
ProductName: cand.Name,
|
||||
Quantity: cand.Quantity,
|
||||
Price: price,
|
||||
Type: cand.Type,
|
||||
})
|
||||
}
|
||||
|
||||
pools = append(pools, PoolInfo{
|
||||
Key: pool.Key,
|
||||
Name: pool.Name,
|
||||
Points: pts,
|
||||
RewardsEarned: earned,
|
||||
RewardsClaimed: redeemed,
|
||||
RewardsAvailable: available,
|
||||
EligibleConfigs: eligibleConfigs,
|
||||
EligibleRewardItems: eligibleRewardItems,
|
||||
})
|
||||
}
|
||||
|
||||
// Aperçu global des produits récompense, 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(candidates))
|
||||
for _, cand := range candidates {
|
||||
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rewardItems = append(rewardItems, RewardItemResponse{
|
||||
ProductID: cand.ProductID,
|
||||
ProductName: cand.Name,
|
||||
Quantity: cand.Quantity,
|
||||
Price: price,
|
||||
Type: cand.Type,
|
||||
})
|
||||
}
|
||||
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 — 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
|
||||
}
|
||||
|
||||
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur résolution produits récompense", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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(candidates))
|
||||
for _, cand := range candidates {
|
||||
if !poolCategories[cand.Category] {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||
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
|
||||
}
|
||||
eligibleItems = append(eligibleItems, models.RewardItem{
|
||||
ProductID: cand.ProductID,
|
||||
Quantity: cand.Quantity,
|
||||
Price: price,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Sans produit éligible pour ce pool (ex: catégories de la récompense mal
|
||||
// alignées avec celles du pool), on refuse avant de consommer un point —
|
||||
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
|
||||
// jamais ajouté au panier (récompense perdue silencieusement).
|
||||
if len(itemsToAdd) == 0 {
|
||||
log.Printf("❌ [CLAIM] Aucun produit éligible pour %s (pool=%s)", username, req.PoolKey)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||
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})
|
||||
}
|
||||
+244
-332
@@ -4,13 +4,12 @@ import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -18,6 +17,10 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// CONFIGURATION & LIMITES
|
||||
// ============================================
|
||||
|
||||
const (
|
||||
MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier
|
||||
MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total
|
||||
@@ -27,6 +30,7 @@ const (
|
||||
MaxProductsPerUser = 100 // Limite pour éviter spam
|
||||
)
|
||||
|
||||
// ✅ MIME types autorisés (vérification réelle du contenu)
|
||||
var allowedMimeTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
@@ -37,6 +41,28 @@ var allowedMimeTypes = map[string]bool{
|
||||
"video/quicktime": true,
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MIDDLEWARE D'AUTHORIZATION
|
||||
// ============================================
|
||||
|
||||
func RequireAdminOrCabine() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès refusé - Admin ou Cabine requis",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS DE VALIDATION
|
||||
// ============================================
|
||||
|
||||
func validateProductName(name string) error {
|
||||
if len(name) == 0 {
|
||||
return fmt.Errorf("nom requis")
|
||||
@@ -117,6 +143,7 @@ func validateCategory(database *db.Database, category string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
|
||||
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
@@ -130,6 +157,7 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||
}
|
||||
|
||||
mimeType := mtype.String()
|
||||
// Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...")
|
||||
if idx := strings.Index(mimeType, ";"); idx != -1 {
|
||||
mimeType = strings.TrimSpace(mimeType[:idx])
|
||||
}
|
||||
@@ -141,9 +169,32 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||
return mimeType, nil
|
||||
}
|
||||
|
||||
// ✅ PROTECTION CONTRE PATH TRAVERSAL
|
||||
func sanitizeFilePath(path string) (string, error) {
|
||||
// Nettoyer le chemin
|
||||
cleaned := filepath.Clean(path)
|
||||
|
||||
// Vérifier qu'il ne contient pas de ".."
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return "", fmt.Errorf("path traversal détecté")
|
||||
}
|
||||
|
||||
// Vérifier qu'il commence par "uploads/"
|
||||
if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") {
|
||||
return "", fmt.Errorf("chemin invalide")
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CREATE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func CreateProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
@@ -152,12 +203,14 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
username, _ := safeGetUsername(c)
|
||||
|
||||
// ✅ PARSER AVEC LIMITE DE TAILLE
|
||||
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
|
||||
name := strings.TrimSpace(c.PostForm("name"))
|
||||
category := strings.TrimSpace(c.PostForm("category"))
|
||||
description := strings.TrimSpace(c.PostForm("description"))
|
||||
@@ -167,6 +220,7 @@ func CreateProduct(c *gin.Context) {
|
||||
unit = "u"
|
||||
}
|
||||
|
||||
// ✅ VALIDATION STRICTE
|
||||
if err := validateProductName(name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -177,6 +231,7 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
category = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
@@ -195,6 +250,7 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VALIDER LE STOCK
|
||||
stock, err := strconv.ParseFloat(stockStr, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
|
||||
@@ -206,13 +262,13 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
|
||||
prices := []models.ProductPrice{}
|
||||
priceIndex := 0
|
||||
|
||||
for priceIndex < 100 {
|
||||
for priceIndex < 100 { // Limite anti-spam
|
||||
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
|
||||
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
|
||||
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
|
||||
|
||||
quantityStr := c.PostForm(quantityKey)
|
||||
priceStr := c.PostForm(priceKey)
|
||||
@@ -238,13 +294,9 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
activePriceStr := c.PostForm(activePriceKey)
|
||||
activePrice := activePriceStr != "false"
|
||||
|
||||
prices = append(prices, models.ProductPrice{
|
||||
Quantity: quantity,
|
||||
Price: price,
|
||||
ActivePrice: activePrice,
|
||||
Quantity: quantity,
|
||||
Price: price,
|
||||
})
|
||||
|
||||
priceIndex++
|
||||
@@ -257,8 +309,6 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
|
||||
|
||||
comingSoon := c.PostForm("coming_soon") == "true"
|
||||
|
||||
// ✅ CRÉER LE PRODUIT
|
||||
product := models.Product{
|
||||
Name: name,
|
||||
@@ -266,7 +316,6 @@ func CreateProduct(c *gin.Context) {
|
||||
Description: description,
|
||||
Stock: stock,
|
||||
Unit: unit,
|
||||
ComingSoon: comingSoon,
|
||||
Prices: prices,
|
||||
}
|
||||
|
||||
@@ -297,6 +346,7 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ LIMITER LE NOMBRE DE FICHIERS
|
||||
if len(files) > MaxFilesPerProduct {
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -309,13 +359,13 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
cleanProductName := cleanFileName(product.Name)
|
||||
uploadedMedia := []models.Media{}
|
||||
savedFiles := []models.Media{}
|
||||
storage := c.MustGet("storage").(services.Storage)
|
||||
savedFiles := []string{}
|
||||
var totalSize int64 = 0
|
||||
|
||||
for i, fileHeader := range files {
|
||||
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
|
||||
if fileHeader.Size > MaxFileSize {
|
||||
rollbackFiles(storage, savedFiles)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)),
|
||||
@@ -324,8 +374,10 @@ func CreateProduct(c *gin.Context) {
|
||||
}
|
||||
|
||||
totalSize += fileHeader.Size
|
||||
|
||||
// ✅ VÉRIFIER LA TAILLE TOTALE
|
||||
if totalSize > MaxTotalUploadSize {
|
||||
rollbackFiles(storage, savedFiles)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
|
||||
@@ -335,50 +387,76 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename)
|
||||
|
||||
// ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension)
|
||||
mimeType, err := validateFileMimeType(fileHeader)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ DÉTERMINER LE TYPE DE MÉDIA
|
||||
var mediaType string
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
mediaType = "image"
|
||||
} else if strings.HasPrefix(mimeType, "video/") {
|
||||
mediaType = "video"
|
||||
} else {
|
||||
rollbackFiles(storage, savedFiles)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ
|
||||
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
|
||||
|
||||
mediaURL, mediaKey, err := storage.Upload(fileHeader, mediaType+"s", uniqueFileName)
|
||||
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
|
||||
destFolder := filepath.Join("uploads", mediaType+"s")
|
||||
if err := os.MkdirAll(destFolder, 0755); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur système"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(destFolder, uniqueFileName)
|
||||
|
||||
// ✅ VALIDER LE CHEMIN (protection path traversal)
|
||||
safeFilePath, err := sanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateProduct] Path traversal détecté: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SAUVEGARDER LE FICHIER
|
||||
if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
|
||||
return
|
||||
}
|
||||
|
||||
savedFiles = append(savedFiles, models.Media{URL: mediaURL, Key: mediaKey})
|
||||
savedFiles = append(savedFiles, safeFilePath)
|
||||
|
||||
// ✅ CRÉER L'ENTRÉE MÉDIA
|
||||
mediaURL := "/" + filepath.ToSlash(safeFilePath)
|
||||
media := models.Media{
|
||||
ProductID: product.ID,
|
||||
Type: mediaType,
|
||||
URL: mediaURL,
|
||||
Key: mediaKey,
|
||||
}
|
||||
|
||||
if err := database.CreateMedia(&media); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
||||
return
|
||||
@@ -397,6 +475,10 @@ func CreateProduct(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET ENDPOINTS - SÉCURISÉS (lecture publique OK)
|
||||
// ============================================
|
||||
|
||||
func GetAllProducts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -409,11 +491,7 @@ func GetAllProducts(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
products = filterActivePrices(products)
|
||||
}
|
||||
products = applyPromotions(products, database)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": products,
|
||||
@@ -426,6 +504,7 @@ func GetProductsByCategory(c *gin.Context) {
|
||||
|
||||
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateCategory(database, category); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
@@ -444,15 +523,12 @@ func GetProductsByCategory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Charger les médias
|
||||
for i := range products {
|
||||
media, _ := database.GetMediaByProductID(products[i].ID)
|
||||
products[i].Media = media
|
||||
}
|
||||
roleCtx := c.GetString("role")
|
||||
if roleCtx != "admin" && roleCtx != "cabine" {
|
||||
products = filterActivePrices(products)
|
||||
}
|
||||
products = applyPromotions(products, database)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": products,
|
||||
@@ -462,6 +538,7 @@ func GetProductsByCategory(c *gin.Context) {
|
||||
|
||||
func GetProductByID(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{
|
||||
@@ -470,6 +547,7 @@ func GetProductByID(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
product, err := database.GetProductByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -478,24 +556,25 @@ func GetProductByID(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Charger les médias
|
||||
media, _ := database.GetMediaByProductID(product.ID)
|
||||
product.Media = media
|
||||
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
filterActivepricesSingle(&product)
|
||||
}
|
||||
applyPromotionsSingle(&product, database)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": product,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UPDATE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func UpdateProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
@@ -510,172 +589,93 @@ func UpdateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = database.GetProductByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
var updateData struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Unit string `json:"unit"`
|
||||
Prices []models.ProductPrice `json:"prices"`
|
||||
Stock *float64 `json:"stock"`
|
||||
ComingSoon *bool `json:"coming_soon"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&updateData); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateProductName(updateData.Name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateProductDescription(updateData.Description); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateCategory(database, updateData.Category); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if updateData.Unit == "" {
|
||||
updateData.Unit = "u"
|
||||
}
|
||||
|
||||
if err := validateUnit(updateData.Unit); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if len(updateData.Prices) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, price := range updateData.Prices {
|
||||
if err := validatePrice(price.Quantity, price.Price); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if updateData.Stock != nil {
|
||||
if err := validateStock(*updateData.Stock); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔄 [UpdateProduct] %s met à jour produit #%d", username, id)
|
||||
|
||||
comingSoon := false
|
||||
if updateData.ComingSoon != nil {
|
||||
comingSoon = *updateData.ComingSoon
|
||||
}
|
||||
|
||||
if err := database.UpdateProduct(id, updateData.Name, updateData.Category, updateData.Description, updateData.Unit, comingSoon, updateData.Prices); err != nil {
|
||||
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
if updateData.Stock != nil {
|
||||
if err := database.SetProductStock(id, *updateData.Stock); err != nil {
|
||||
log.Printf("⚠️ [UpdateProduct] Erreur mise à jour stock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
|
||||
updatedProduct, _ := database.GetProductByID(id)
|
||||
media, _ := database.GetMediaByProductID(id)
|
||||
updatedProduct.Media = media
|
||||
|
||||
log.Printf("✅ [UpdateProduct] Produit #%d mis à jour", id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"product": updatedProduct,
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateStock(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
role := c.GetString("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username, _ := safeGetUsername(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
||||
_, err = database.GetProductByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Stock float64 `json:"stock"`
|
||||
|
||||
var updateData struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Stock float64 `json:"stock"`
|
||||
Unit string `json:"unit"`
|
||||
Prices []models.ProductPrice `json:"prices"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
if err := c.ShouldBindJSON(&updateData); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
if err := validateStock(req.Stock); err != nil {
|
||||
|
||||
// ✅ VALIDATION COMPLÈTE
|
||||
if err := validateProductName(updateData.Name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
reserved, err := database.GetReservedQuantityInBaskets(id)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture réservations", err)
|
||||
return
|
||||
}
|
||||
if req.Stock+reserved < reserved {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
|
||||
if err := validateProductDescription(updateData.Description); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🔄 [UpdateStock] %s met à jour le stock #%d (réservé en paniers: %.3f)", username, id, reserved)
|
||||
if err := validateCategory(database, updateData.Category); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.SetProductStock(id, req.Stock); err != nil {
|
||||
if updateData.Unit == "" {
|
||||
updateData.Unit = "u"
|
||||
}
|
||||
if err := validateUnit(updateData.Unit); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateStock(updateData.Stock); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if len(updateData.Prices) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, price := range updateData.Prices {
|
||||
if err := validatePrice(price.Quantity, price.Price); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔄 [UpdateProduct] %s met à jour produit #%d", username, id)
|
||||
|
||||
if err := database.UpdateProduct(id, updateData.Name, updateData.Category, updateData.Description, updateData.Unit, updateData.Stock, updateData.Prices); err != nil {
|
||||
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
|
||||
updatedProduct, _ := database.GetProductByID(id)
|
||||
media, _ := database.GetMediaByProductID(id)
|
||||
updatedProduct.Media = media
|
||||
|
||||
log.Printf("✅ [UpdateStock] le stock #%d est mis à jour", id)
|
||||
log.Printf("✅ [UpdateProduct] Produit #%d mis à jour", id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"product": updatedProduct,
|
||||
"reserved_in_baskets": reserved,
|
||||
"success": true,
|
||||
"product": updatedProduct,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func DeleteMedia(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
@@ -694,25 +694,26 @@ func DeleteMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteMedia(mediaID); err != nil {
|
||||
log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
// ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION
|
||||
filePath := strings.TrimPrefix(media.URL, "/")
|
||||
|
||||
safeFilePath, err := sanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DeleteMedia] Path invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if media.Key != "" {
|
||||
if err := s3Service.DeleteFile(media.Key); err != nil {
|
||||
log.Printf("⚠️ [DeleteMedia] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
|
||||
} else {
|
||||
log.Printf("✅ [DeleteMedia] Fichier supprimé sur RustFS: %s", media.Key)
|
||||
}
|
||||
} else {
|
||||
localStorage := services.NewLocalStorage("uploads")
|
||||
if err := localStorage.Delete(media.URL, ""); err != nil {
|
||||
log.Printf("⚠️ [DeleteMedia] Fichier local non supprimé (%s): %v", media.URL, err)
|
||||
} else {
|
||||
log.Printf("✅ [DeleteMedia] Fichier local supprimé: %s", media.URL)
|
||||
}
|
||||
// ✅ SUPPRIMER LE FICHIER PHYSIQUE
|
||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("⚠️ [DeleteMedia] Erreur suppression fichier: %v", err)
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER DE LA DB
|
||||
err = database.DeleteMedia(mediaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -724,6 +725,7 @@ func DeleteMedia(c *gin.Context) {
|
||||
func UploadMedia(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
@@ -731,17 +733,19 @@ func UploadMedia(c *gin.Context) {
|
||||
}
|
||||
|
||||
role := c.GetString("role")
|
||||
if role != "admin" {
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT
|
||||
productID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || productID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
||||
productName, err := database.GetProductNameByID(productID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
@@ -750,6 +754,7 @@ func UploadMedia(c *gin.Context) {
|
||||
|
||||
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName)
|
||||
|
||||
// ✅ RÉCUPÉRER LE TYPE ET LE FICHIER
|
||||
fileType := c.PostForm("type")
|
||||
if fileType != "image" && fileType != "video" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
|
||||
@@ -763,6 +768,7 @@ func UploadMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER LA TAILLE
|
||||
const MaxFileSize = 10 * 1024 * 1024 // 10MB
|
||||
if file.Size > MaxFileSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -771,6 +777,7 @@ func UploadMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER LE TYPE MIME RÉEL
|
||||
detectedMime, err := validateFileMimeType(file)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
|
||||
@@ -780,6 +787,7 @@ func UploadMedia(c *gin.Context) {
|
||||
|
||||
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime)
|
||||
|
||||
// Vérifier que le MIME correspond au type déclaré
|
||||
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
|
||||
return
|
||||
@@ -789,32 +797,40 @@ func UploadMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ GÉNÉRER UN NOM UNIQUE
|
||||
cleanProductName := cleanFileName(productName)
|
||||
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
|
||||
|
||||
storage := c.MustGet("storage").(services.Storage)
|
||||
folder := fileType + "s"
|
||||
mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur upload: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"})
|
||||
// ✅ CRÉER LE DOSSIER
|
||||
destFolder := filepath.Join("uploads", fileType+"s")
|
||||
if err := os.MkdirAll(destFolder, 0755); err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [UploadMedia] Fichier uploadé: %s", mediaURL)
|
||||
// ✅ SAUVEGARDER LE FICHIER
|
||||
filePath := filepath.Join(destFolder, uniqueFileName)
|
||||
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur sauvegarde: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath)
|
||||
|
||||
// ✅ CRÉER L'ENTRÉE EN BASE
|
||||
mediaURL := "/" + filepath.ToSlash(filePath)
|
||||
media := models.Media{
|
||||
ProductID: productID,
|
||||
Type: fileType,
|
||||
URL: mediaURL,
|
||||
Key: mediaKey,
|
||||
}
|
||||
|
||||
err = database.CreateMedia(&media)
|
||||
if err != nil {
|
||||
if delErr := storage.Delete(mediaURL, mediaKey); delErr != nil {
|
||||
log.Printf("⚠️ [UploadMedia] Échec rollback (%s): %v", mediaURL, delErr)
|
||||
}
|
||||
// Rollback: supprimer le fichier
|
||||
os.Remove(filePath)
|
||||
log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
||||
return
|
||||
@@ -833,71 +849,9 @@ func UploadMedia(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func ServeMedia(c *gin.Context) {
|
||||
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||
|
||||
key := strings.TrimPrefix(c.Param("key"), "/")
|
||||
if key == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Clé manquante"})
|
||||
return
|
||||
}
|
||||
|
||||
body, contentType, err := s3Service.GetFile(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, body)
|
||||
}
|
||||
|
||||
func ActivePrice(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
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.AddActivePrice(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Prix activé avec succès"})
|
||||
}
|
||||
|
||||
func DesActivePrice(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
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.DeActivePrice(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"})
|
||||
}
|
||||
// ============================================
|
||||
// DELETE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func DeleteProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
@@ -919,28 +873,32 @@ func DeleteProduct(c *gin.Context) {
|
||||
|
||||
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
|
||||
|
||||
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
|
||||
mediaList, err := database.GetMediaByProductID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
|
||||
return
|
||||
}
|
||||
|
||||
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||
localStorage := services.NewLocalStorage("uploads")
|
||||
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ
|
||||
for _, media := range mediaList {
|
||||
if media.Key != "" {
|
||||
if err := s3Service.DeleteFile(media.Key); err != nil {
|
||||
log.Printf("⚠️ [DeleteProduct] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
|
||||
}
|
||||
} else {
|
||||
if err := localStorage.Delete(media.URL, ""); err != nil {
|
||||
log.Printf("⚠️ [DeleteProduct] Erreur suppression locale: %v", err)
|
||||
}
|
||||
filePath := strings.TrimPrefix(media.URL, "/")
|
||||
|
||||
safeFilePath, err := sanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("⚠️ [DeleteProduct] Erreur suppression: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER LES MÉDIAS DE LA DB
|
||||
database.DeleteMediaByProductID(id)
|
||||
|
||||
// ✅ SUPPRIMER LE PRODUIT
|
||||
err = database.DeleteProduct(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
|
||||
@@ -955,11 +913,17 @@ func DeleteProduct(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func rollbackFiles(storage services.Storage, files []models.Media) {
|
||||
for _, f := range files {
|
||||
if err := storage.Delete(f.URL, f.Key); err != nil {
|
||||
log.Printf("⚠️ [rollbackFiles] Erreur suppression %s: %v", f.URL, err)
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
func rollbackFiles(files []string) {
|
||||
for _, file := range files {
|
||||
safeFilePath, err := sanitizeFilePath(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
os.Remove(safeFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,55 +947,3 @@ func cleanFileName(name string) string {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func filterActivePrices(products []models.Product) []models.Product {
|
||||
for i := range products {
|
||||
activePrices := []models.ProductPrice{}
|
||||
for _, p := range products[i].Prices {
|
||||
if p.ActivePrice {
|
||||
activePrices = append(activePrices, p)
|
||||
}
|
||||
}
|
||||
products[i].Prices = activePrices
|
||||
}
|
||||
return products
|
||||
}
|
||||
|
||||
func filterActivepricesSingle(product *models.Product) {
|
||||
activePrices := []models.ProductPrice{}
|
||||
for _, p := range product.Prices {
|
||||
if p.ActivePrice {
|
||||
activePrices = append(activePrices, p)
|
||||
}
|
||||
}
|
||||
product.Prices = activePrices
|
||||
}
|
||||
|
||||
// applyPromotions annote chaque palier de prix éligible avec le prix promo
|
||||
// (PromoPrice/PromoPercent) si une promotion couvre ce produit/quantité —
|
||||
// affichage seulement, le prix catalogue (Price) n'est jamais modifié ici ;
|
||||
// le prix réellement facturé est recalculé indépendamment dans AddToBasket.
|
||||
func applyPromotions(products []models.Product, database *db.Database) []models.Product {
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil || !settings.PromotionsEnabled {
|
||||
return products
|
||||
}
|
||||
for i := range products {
|
||||
for j := range products[i].Prices {
|
||||
pr := &products[i].Prices[j]
|
||||
discount, ok := db.ResolvePromotionDiscount(&settings, products[i].ID, products[i].Category, pr.Quantity)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
promoPrice := math.Round(pr.Price*(1-discount/100)*100) / 100
|
||||
pr.PromoPrice = &promoPrice
|
||||
pr.PromoPercent = discount
|
||||
}
|
||||
}
|
||||
return products
|
||||
}
|
||||
|
||||
func applyPromotionsSingle(product *models.Product, database *db.Database) {
|
||||
products := applyPromotions([]models.Product{*product}, database)
|
||||
*product = products[0]
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
// ============================================
|
||||
// handlers/redis_handlers.go - VERSION FINALE
|
||||
// UTILISE UNIQUEMENT LES MÉTHODES DB PostgreSQL
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -8,7 +13,6 @@ import (
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,6 +20,10 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// GESTION DE LA FILE DE COMMANDES
|
||||
// ============================================
|
||||
|
||||
func validatePenaltyPoints(points int) error {
|
||||
if points <= 0 {
|
||||
return fmt.Errorf("points invalides: %d (doit être > 0)", points)
|
||||
@@ -39,6 +47,72 @@ func sanitizeReason(reason string) string {
|
||||
return strings.TrimSpace(reason)
|
||||
}
|
||||
|
||||
// GetCommandQueue récupère toutes les commandes en attente dans la file Redis
|
||||
// GET /api/v2/admin/protected/queue/pending
|
||||
func GetCommandQueue(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
|
||||
}
|
||||
|
||||
nextCommand, err := database.GetNextCommandInQueue()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Aucune commande en attente",
|
||||
"queue": []interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"next_command": nextCommand,
|
||||
})
|
||||
}
|
||||
|
||||
// AutoAssignNextCommand assigne automatiquement la prochaine commande en file
|
||||
// POST /api/v2/admin/protected/queue/auto-assign
|
||||
func AutoAssignNextCommand(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
|
||||
}
|
||||
|
||||
nextCommand, err := database.GetNextCommandInQueue()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucune commande en attente",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.AutoAssignCommand(nextCommand.CommandID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de l'assignation automatique", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée automatiquement",
|
||||
"command_id": nextCommand.CommandID,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GESTION DES LIVREURS - LOCALISATION
|
||||
// ============================================
|
||||
|
||||
// UpdateLivreurLocation met à jour la position GPS du livreur
|
||||
// POST /api/v1/livreur/location/update
|
||||
// Body: {"latitude": 48.8566, "longitude": 2.3522}
|
||||
func UpdateLivreurLocation(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -97,7 +171,7 @@ func UpdateLivreurLocation(c *gin.Context) {
|
||||
usernameStr, req.Latitude, req.Longitude)
|
||||
|
||||
// ✅ Recalculer l'ETA en temps réel si livreur en_route
|
||||
go refreshETAForActivDelivery(usernameStr, req.Latitude, req.Longitude)
|
||||
go refreshETAForActivDelivery(database, usernameStr, req.Latitude, req.Longitude)
|
||||
|
||||
// ✅ 2. Vérifier/Initialiser le statut du livreur
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", usernameStr)
|
||||
@@ -216,6 +290,14 @@ func GetDeliveryPersonLocation(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LOCALISATION DU LIVREUR POUR UNE COMMANDE
|
||||
// ============================================
|
||||
|
||||
// GetDeliverymanLocationForCommand récupère la position GPS du livreur assigné à une commande
|
||||
// GET /api/v2/admin/protected/commands/:id/deliveryman/location (ADMIN)
|
||||
// GET /api/v1/cabine/commands/:id/deliveryman/location (CABINE)
|
||||
// Accessible uniquement par les admins et la cabine
|
||||
func GetDeliverymanLocationForCommand(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -307,22 +389,19 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible)
|
||||
// La clé est un hash (HSet), jamais une simple valeur — Redis.Get renvoie
|
||||
// une erreur WRONGTYPE dessus, silencieusement ignorée ici auparavant,
|
||||
// ce qui faisait toujours renvoyer etaMinutes=0.
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
eta, _ := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
||||
etaData, _ := db.Redis.Get(db.RedisCtx, etaKey).Result()
|
||||
|
||||
var etaMinutes int = 0
|
||||
var etaSetAt int64 = 0
|
||||
if minutesStr, ok := eta["eta_minutes"]; ok {
|
||||
if minutes, err := strconv.Atoi(minutesStr); err == nil {
|
||||
etaMinutes = minutes
|
||||
if etaData != "" {
|
||||
var eta map[string]interface{}
|
||||
json.Unmarshal([]byte(etaData), &eta)
|
||||
if minutes, ok := eta["minutes"].(float64); ok {
|
||||
etaMinutes = int(minutes)
|
||||
}
|
||||
}
|
||||
if updatedAtStr, ok := eta["updated_at"]; ok {
|
||||
if timestamp, err := strconv.ParseInt(updatedAtStr, 10, 64); err == nil {
|
||||
etaSetAt = timestamp
|
||||
if timestamp, ok := eta["set_at"].(float64); ok {
|
||||
etaSetAt = int64(timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +460,13 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GESTION DES LIVREURS - STATUT
|
||||
// ============================================
|
||||
|
||||
// UpdateDeliveryPersonStatus met à jour le statut de disponibilité du livreur
|
||||
// POST /api/v1/livreur/status
|
||||
// Body: {"status": "available" | "busy" | "offline"}
|
||||
func UpdateDeliveryPersonStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -404,9 +490,18 @@ func UpdateDeliveryPersonStatus(c *gin.Context) {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
validStatuses := []string{"available", "busy", "offline"}
|
||||
|
||||
if !slices.Contains(validStatuses, req.Status) {
|
||||
// Validation du statut
|
||||
validStatuses := []string{"available", "busy", "offline"}
|
||||
isValid := false
|
||||
for _, s := range validStatuses {
|
||||
if req.Status == s {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isValid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut invalide",
|
||||
"valid_statuses": validStatuses,
|
||||
@@ -414,6 +509,7 @@ func UpdateDeliveryPersonStatus(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
err := database.SetDeliveryPersonStatus(usernameStr, req.Status, 0)
|
||||
@@ -508,6 +604,118 @@ func GetMyQueue(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsRealtime récupère les livreurs disponibles depuis Redis
|
||||
// GET /api/v2/admin/protected/delivery/available-realtime
|
||||
func GetAvailableDeliveryPersonsRealtime(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
|
||||
}
|
||||
|
||||
livreurs, err := database.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la récupération des livreurs", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"livreurs": livreurs,
|
||||
"count": len(livreurs),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GESTION ETA (Estimated Time of Arrival)
|
||||
// ============================================
|
||||
|
||||
// SetCommandETAHandler permet au livreur de définir l'ETA d'une livraison
|
||||
// POST /api/v1/livreur/deliveries/:id/set-eta
|
||||
// Body: {"eta_minutes": 25}
|
||||
func SetCommandETAHandler(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
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ETAMinutes int `json:"eta_minutes" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validation de l'ETA
|
||||
if req.ETAMinutes < 1 || req.ETAMinutes > 120 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "L'ETA doit être entre 1 et 120 minutes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
// Vérifier que la commande existe et est assignée au livreur
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Commande non trouvée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, ok := command["livreur_assign"].(string)
|
||||
if !ok || livreurAssign != usernameStr {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour l'ETA dans Redis
|
||||
err = database.SetCommandETA(commandID, req.ETAMinutes)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la mise à jour de l'ETA", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("⏱️ ETA défini pour commande %d par %s: %d minutes", commandID, usernameStr, req.ETAMinutes)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "ETA mis à jour avec succès",
|
||||
"command_id": commandID,
|
||||
"eta_minutes": req.ETAMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PÉNALITÉS - UTILISE PostgreSQL
|
||||
// ============================================
|
||||
|
||||
// ApplyClientPenalty applique une pénalité à un client (Admin seulement)
|
||||
// POST /api/v2/admin/protected/penalty
|
||||
// Body: {"username": "john", "points": 50, "reason": "Retard paiement"}
|
||||
func ApplyClientPenalty(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -785,6 +993,9 @@ func ResetClientPenaltiesAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// AddClientPointsAdmin ajoute des points à un client dans un pool donné (Admin/Cabine)
|
||||
// POST /api/v2/admin/protected/client/:username/points/add
|
||||
// Body: {"pool_key": "pool_0", "points": 10}
|
||||
func AddClientPointsAdmin(c *gin.Context) {
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
@@ -829,7 +1040,7 @@ func AddClientPointsAdmin(c *gin.Context) {
|
||||
}
|
||||
if !poolExists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Pool de points invalide",
|
||||
"error": "Pool de points invalide",
|
||||
"pools_valides": func() []string {
|
||||
keys := make([]string, 0, len(settings.PointsPools))
|
||||
for _, p := range settings.PointsPools {
|
||||
@@ -862,6 +1073,9 @@ func AddClientPointsAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// SubtractClientPointsAdmin retire des points à un client (plancher à 0)
|
||||
// POST /api/v2/admin/protected/client/:username/points/subtract
|
||||
// Body: {"pool_key": "pool_0", "points": 10}
|
||||
func SubtractClientPointsAdmin(c *gin.Context) {
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
@@ -950,7 +1164,49 @@ func SubtractClientPointsAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func refreshETAForActivDelivery(username string, lat, lon float64) {
|
||||
// ============================================
|
||||
// STATISTIQUES TEMPS RÉEL
|
||||
// ============================================
|
||||
|
||||
// GetRealtimeStats récupère les statistiques en temps réel
|
||||
// GET /api/v2/admin/protected/stats/realtime
|
||||
func GetRealtimeStats(c *gin.Context) {
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := db.Redis.HGetAll(db.RedisCtx, "stats:realtime").Result()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération des statistiques",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(stats) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Aucune statistique disponible pour le moment",
|
||||
"stats": map[string]interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RECALCUL ETA EN TEMPS RÉEL (appelé à chaque update GPS)
|
||||
// ============================================
|
||||
|
||||
// refreshETAForActivDelivery recalcule l'ETA depuis la position actuelle du livreur.
|
||||
// Appelé en goroutine à chaque mise à jour GPS (toutes les ~15s).
|
||||
func refreshETAForActivDelivery(database *db.Database, username string, lat, lon float64) {
|
||||
// 1. Récupérer le statut actuel du livreur
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
||||
statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result()
|
||||
@@ -1020,17 +1276,12 @@ func refreshETAForActivDelivery(username string, lat, lon float64) {
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
// eta_minutes ET total_eta_minutes doivent tous les deux être présents
|
||||
// (voir le commentaire de SetCommandETAWithDetails) — sans quoi les
|
||||
// lecteurs qui attendent l'un ou l'autre nom de champ ne trouvent rien.
|
||||
"eta_minutes": etaMinutes,
|
||||
"total_eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||
"distance_km": distanceKm,
|
||||
"with_traffic": err == nil,
|
||||
"command_id": commandID,
|
||||
"eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"distance_km": distanceKm,
|
||||
"with_traffic": err == nil,
|
||||
})
|
||||
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -47,14 +46,6 @@ func GetPublicSettings(c *gin.Context) {
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,7 +89,7 @@ func UpdateSettings(c *gin.Context) {
|
||||
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))
|
||||
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", webhookURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,473 +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
|
||||
totalPromoDiscount float64
|
||||
promoOrdersCount int64
|
||||
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
|
||||
totalPromoDiscount, err = database.TotalPromoDiscount(filters.ResetRevenus)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
promoOrdersCount, err = database.PromoOrdersCount(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,
|
||||
"total_promo_discount": totalPromoDiscount,
|
||||
"promo_orders_count": promoOrdersCount,
|
||||
"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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -92,29 +91,9 @@ func handleLinkAccount(c *gin.Context, token string, chatID int64) {
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
@@ -139,11 +118,9 @@ func GenerateClientLinkToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
@@ -168,11 +145,9 @@ func GenerateLivreurLinkToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
@@ -205,11 +180,9 @@ func GenerateAdminLinkToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
@@ -324,62 +297,3 @@ func UnlinkAdminTelegram(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// getFloatFromMap récupère un float64 depuis une map avec différents types
|
||||
func getFloatFromMap(m map[string]any, key string) (float64, bool) {
|
||||
func getFloatFromMap(m map[string]interface{}, key string) (float64, bool) {
|
||||
value, exists := m[key]
|
||||
if !exists || value == nil {
|
||||
return 0, false
|
||||
|
||||
@@ -169,6 +169,10 @@ func GetMyProfile(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "client": sanitizeClient(client)})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODIFICATION PROFIL CLIENT (PAR ADMIN)
|
||||
// ============================================
|
||||
|
||||
// UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
|
||||
// PUT /api/v2/admin/protected/clients/:id
|
||||
func UpdateClientByAdmin(c *gin.Context) {
|
||||
@@ -198,6 +202,9 @@ func UpdateClientByAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ LOG DEBUG - Voir ce qui est reçu
|
||||
log.Printf("📝 [UPDATE_CLIENT_ADMIN] Requête reçue: %+v", req)
|
||||
|
||||
// Récupérer le client actuel
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
@@ -342,6 +349,10 @@ func UpdateClientByAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODIFICATION PROFIL USER (PAR ADMIN)
|
||||
// ============================================
|
||||
|
||||
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
|
||||
// PUT /api/v2/admin/protected/users/:id
|
||||
func UpdateUserByAdmin(c *gin.Context) {
|
||||
@@ -457,6 +468,10 @@ func UpdateUserByAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITAIRES
|
||||
// ============================================
|
||||
|
||||
func sanitizeClient(client *models.Client) gin.H {
|
||||
return gin.H{
|
||||
"id": client.ID,
|
||||
|
||||
@@ -12,6 +12,261 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// CONSTANTES DE CONFIGURATION
|
||||
// ============================================
|
||||
|
||||
const (
|
||||
// Distance maximale en mètres pour valider une livraison
|
||||
MAX_DELIVERY_VALIDATION_DISTANCE_METERS = 100 // 100 mètres
|
||||
|
||||
// Distance maximale en kilomètres
|
||||
MAX_DELIVERY_VALIDATION_DISTANCE_KM = 0.1 // 100 mètres = 0.1 km
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 1️⃣ VALIDATION LIVRAISON PAR LE LIVREUR (AVEC VÉRIFICATION GPS)
|
||||
// ============================================
|
||||
|
||||
func ValidateDeliveryByLivreur(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Livreur seulement
|
||||
username, exists := c.Get("username")
|
||||
if !exists || c.GetString("role") != "livreur" {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Accès refusé")
|
||||
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 de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude" binding:"required"`
|
||||
Longitude float64 `json:"longitude" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Erreur JSON: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 [VALIDATE_LIVREUR] Livreur %s valide cmd %d avec GPS: (%.6f, %.6f)",
|
||||
usernameStr, commandID, req.Latitude, req.Longitude)
|
||||
|
||||
// ✅ ÉTAPE 1: Récupérer la commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Commande non trouvée")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 2: VÉRIFIER PROPRIÉTÉ
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != usernameStr {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] ⚠️ TENTATIVE D'ACCÈS NON AUTORISÉ!")
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ÉTAPE 3: Coordonnées GPS reçues et valides
|
||||
log.Printf("📍 [VALIDATE_LIVREUR] GPS reçu: (%.6f, %.6f)", req.Latitude, req.Longitude)
|
||||
|
||||
// ÉTAPE 4: Sauvegarder les coordonnées du livreur
|
||||
_, err = database.Exec(
|
||||
"UPDATE commandes SET livreur_latitude = $1, livreur_longitude = $2 WHERE id = $3",
|
||||
req.Latitude, req.Longitude, commandID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [VALIDATE_LIVREUR] Erreur sauvegarde GPS: %v", err)
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 5: Marquer la livraison comme "livre"
|
||||
if err := database.UpdateCommandStatus(commandID, "livre"); err != nil {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Erreur update statut: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur validation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 6: Ajouter un log
|
||||
database.AddCommandLog(commandID, "livre",
|
||||
fmt.Sprintf("Livraison confirmée par livreur - GPS: (%.6f, %.6f)", req.Latitude, req.Longitude),
|
||||
usernameStr)
|
||||
|
||||
// ✅ ÉTAPE 7: Optimiser la queue
|
||||
log.Printf("📦 [VALIDATE_LIVREUR] Optimisation queue de %s...", usernameStr)
|
||||
err = database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [VALIDATE_LIVREUR] Erreur optimisation: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [VALIDATE_LIVREUR] Commande %d validée et marquée 'livre'", commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Livraison validée avec succès",
|
||||
"command_id": commandID,
|
||||
"new_status": "livre",
|
||||
"gps_verified": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2️⃣ VÉRIFIER SI LE LIVREUR PEUT VALIDER (SANS VALIDER)
|
||||
// ============================================
|
||||
|
||||
// CheckDeliveryValidationEligibility vérifie si le livreur peut valider une livraison
|
||||
// GET /api/v1/deliveries/:id/can-validate
|
||||
func CheckDeliveryValidationEligibility(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
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier l'assignation
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username.(string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": false,
|
||||
"reason": "Commande non assignée à vous",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la position du livreur
|
||||
livreurLat, livreurLon, err := database.GetDeliveryPersonLocation(username.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": false,
|
||||
"reason": "Position GPS non disponible",
|
||||
"action": "Mettez à jour votre position GPS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les coordonnées de destination (même priorité que ValidateDeliveryByLivreur)
|
||||
var destLat, destLon float64
|
||||
var coordsSource string
|
||||
|
||||
// ✅ PRIORITÉ 1: Cache Redis
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, redisErr := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
|
||||
if redisErr == 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
|
||||
coordsSource = "REDIS"
|
||||
log.Printf("📍 [CAN-VALIDATE] Coords depuis Redis: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ PRIORITÉ 2: DB
|
||||
if coordsSource == "" {
|
||||
if dLat, ok := getFloatFromMap(command, "dest_latitude"); ok && dLat != 0 {
|
||||
destLat = dLat
|
||||
}
|
||||
if dLon, ok := getFloatFromMap(command, "dest_longitude"); ok && dLon != 0 {
|
||||
destLon = dLon
|
||||
}
|
||||
if destLat != 0 && destLon != 0 {
|
||||
coordsSource = "DB"
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ PRIORITÉ 3: Géocodage
|
||||
if coordsSource == "" {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
address, _ := command["adresse"].(string)
|
||||
if address != "" && address != "Adresse non spécifiée" {
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err == nil {
|
||||
destLat = location.Latitude
|
||||
destLon = location.Longitude
|
||||
coordsSource = "GEOCODING"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if destLat == 0 || destLon == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": false,
|
||||
"reason": "Coordonnées de destination non disponibles",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculer la distance
|
||||
distance := services.CalculateDistance(
|
||||
services.Coordinates{Latitude: livreurLat, Longitude: livreurLon},
|
||||
services.Coordinates{Latitude: destLat, Longitude: destLon},
|
||||
)
|
||||
|
||||
distanceMeters := distance * 1000
|
||||
canValidate := distance <= MAX_DELIVERY_VALIDATION_DISTANCE_KM
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": canValidate,
|
||||
"your_position": gin.H{
|
||||
"latitude": livreurLat,
|
||||
"longitude": livreurLon,
|
||||
},
|
||||
"destination": gin.H{
|
||||
"latitude": destLat,
|
||||
"longitude": destLon,
|
||||
"address": command["adresse"],
|
||||
"source": coordsSource,
|
||||
},
|
||||
"distance_meters": int(distanceMeters),
|
||||
"max_allowed_meters": MAX_DELIVERY_VALIDATION_DISTANCE_METERS,
|
||||
"remaining_meters": maxInt(0, int(distanceMeters)-MAX_DELIVERY_VALIDATION_DISTANCE_METERS),
|
||||
"message": func() string {
|
||||
if canValidate {
|
||||
return "Vous pouvez valider cette livraison"
|
||||
}
|
||||
return fmt.Sprintf("Rapprochez-vous de %.0f mètres pour valider", distanceMeters-float64(MAX_DELIVERY_VALIDATION_DISTANCE_METERS))
|
||||
}(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
|
||||
// ============================================
|
||||
@@ -20,7 +275,6 @@ import (
|
||||
// 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" {
|
||||
@@ -105,47 +359,11 @@ func StartDelivery(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if etaMinutes == 0 {
|
||||
if etaMinutes == 0 && req.Latitude != 0 && req.Longitude != 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 {
|
||||
@@ -177,3 +395,14 @@ func StartDelivery(c *gin.Context) {
|
||||
"status": "en_route",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
+5
-53
@@ -1,3 +1,7 @@
|
||||
// ============================================
|
||||
// main.go - VERSION SIMPLIFIÉE AVEC CLEANUP AUTO
|
||||
// ============================================
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -38,13 +42,6 @@ func main() {
|
||||
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é")
|
||||
@@ -72,49 +69,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -176,8 +130,6 @@ func main() {
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("database", database)
|
||||
c.Set("geoService", geoService)
|
||||
c.Set("s3Service", s3Service)
|
||||
c.Set("storage", storage)
|
||||
c.Next()
|
||||
})
|
||||
|
||||
@@ -192,7 +144,7 @@ func main() {
|
||||
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
routes.SetupRoutes(r, database, geoService, s3Service)
|
||||
routes.SetupRoutes(r, database, geoService)
|
||||
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -61,7 +60,7 @@ func BlockClientIfPenalty(c *gin.Context) {
|
||||
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),
|
||||
"error": "Commande bloquée : vous avez une amende en attente de paiement",
|
||||
"amende": amende,
|
||||
"blocked": true,
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ func OrderHoursMiddleware(c *gin.Context) {
|
||||
hour := now.Hour()
|
||||
min := now.Minute()
|
||||
|
||||
// Récupérer le planning depuis les settings DB
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
|
||||
@@ -54,7 +54,7 @@ var (
|
||||
// ============================================
|
||||
|
||||
// validateClientToken valide un token client
|
||||
func validateClientToken(tokenString string) (*ClientClaims, error) {
|
||||
func validateClientToken(tokenString string, database *db.Database) (*ClientClaims, error) {
|
||||
tokenString = strings.TrimSpace(tokenString)
|
||||
if tokenString == "" {
|
||||
return nil, fmt.Errorf("token vide")
|
||||
@@ -103,7 +103,7 @@ func validateClientToken(tokenString string) (*ClientClaims, error) {
|
||||
}
|
||||
|
||||
// validateAdminToken valide un token admin
|
||||
func validateAdminToken(tokenString string) (*AdminClaims, error) {
|
||||
func validateAdminToken(tokenString string, database *db.Database) (*AdminClaims, error) {
|
||||
tokenString = strings.TrimSpace(tokenString)
|
||||
if tokenString == "" {
|
||||
return nil, fmt.Errorf("token vide")
|
||||
@@ -161,7 +161,7 @@ func ClientMiddleware(c *gin.Context) {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateClientToken(tokenStr)
|
||||
claims, err := validateClientToken(tokenStr, database)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
@@ -205,7 +205,7 @@ func AdminMiddleware(c *gin.Context) {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
claims, err := validateAdminToken(tokenStr, database)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
|
||||
@@ -258,7 +258,7 @@ func CabineMiddleware(c *gin.Context) {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
claims, err := validateAdminToken(tokenStr, database)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
@@ -312,7 +312,7 @@ func LivreurMiddleware(c *gin.Context) {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
claims, err := validateAdminToken(tokenStr, database)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
@@ -507,3 +507,94 @@ func LoginRateLimitMiddleware(c *gin.Context) {
|
||||
c.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10))
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER MIDDLEWARE
|
||||
// ============================================
|
||||
|
||||
// VerifyAuthHeader vérifie que le header Authorization est valide
|
||||
func VerifyAuthHeader(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
|
||||
if authHeader == "" {
|
||||
log.Printf("❌ [AUTH-HEADER] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authorization header manquant",
|
||||
"hint": "Utilisez: Authorization: Bearer <token>",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier le format "Bearer <token>"
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
log.Printf("❌ [AUTH-HEADER] Format invalide: %s", authHeader)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Format Authorization invalide",
|
||||
"hint": "Utilisez: Authorization: Bearer <token>",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [AUTH-HEADER] Format valide")
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// SessionErrorRecovery récupère les erreurs de session
|
||||
func SessionErrorRecovery(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("❌ [SESSION-ERROR] Erreur système: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur serveur - Session compromise",
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
|
||||
if len(c.Errors) > 0 {
|
||||
log.Printf("⚠️ [SESSION] Erreur handler: %v", c.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
// LogSessionMiddleware log toutes les infos de session
|
||||
func LogSessionMiddleware(c *gin.Context) {
|
||||
username, _ := c.Get("username")
|
||||
clientID, _ := c.Get("client_id")
|
||||
sessionID, _ := c.Get("session_id")
|
||||
|
||||
log.Printf("📊 [SESSION-LOG] %s %s | user=%v | client_id=%v | session=%v",
|
||||
c.Request.Method, c.Request.URL.Path, username, clientID, sessionID)
|
||||
|
||||
c.Next()
|
||||
|
||||
log.Printf("📊 [SESSION-LOG] Response: %d", c.Writer.Status())
|
||||
}
|
||||
|
||||
// LoadClientContext charge les infos du client en contexte
|
||||
func LoadClientContext(c *gin.Context, database *db.Database) (*db.SessionData, error) {
|
||||
clientID, ok := c.Get("client_id")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("client_id manquant du contexte")
|
||||
}
|
||||
|
||||
clientIDInt := clientID.(int)
|
||||
|
||||
// Récupérer la session
|
||||
session, err := database.GetClientSession(clientIDInt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func DatabaseMiddleware(db *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("database", db)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,6 @@ type AdminClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// STRUCTURES REQUÊTE / RÉPONSE
|
||||
// ============================================
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
@@ -38,7 +34,7 @@ type RegisterClientRequest struct {
|
||||
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"`
|
||||
Role string `json:"role" binding:"required,oneof=admin cabine livreur"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
|
||||
@@ -18,7 +18,6 @@ type Client struct {
|
||||
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"`
|
||||
|
||||
@@ -19,17 +19,14 @@ 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"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount" json:"promo_discount,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
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"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
type CommandLog struct {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package models
|
||||
|
||||
type Contact struct {
|
||||
ID int `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
}
|
||||
@@ -3,12 +3,11 @@ package models
|
||||
import "time"
|
||||
|
||||
type Media struct {
|
||||
ID int `json:"id"`
|
||||
ProductID int `json:"product_id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Key string `json:"-"` // clé interne RustFS, jamais exposée
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||
Type string `gorm:"column:type" json:"type"`
|
||||
URL string `gorm:"column:url" json:"url"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (Media) TableName() string { return "media" }
|
||||
|
||||
@@ -3,17 +3,14 @@ package models
|
||||
import "time"
|
||||
|
||||
type Panier struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
IsReward bool `json:"is_reward"`
|
||||
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
||||
PromoDiscount float64 `json:"promo_discount,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -3,17 +3,16 @@ package models
|
||||
import "time"
|
||||
|
||||
type Product struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"column:name" binding:"required"`
|
||||
Category string `json:"category" gorm:"column:category" binding:"required"`
|
||||
Description string `json:"description" gorm:"column:description"`
|
||||
Stock float64 `json:"stock" gorm:"column:stock"`
|
||||
Unit string `json:"unit" gorm:"column:unit"`
|
||||
ComingSoon bool `json:"coming_soon" gorm:"column:coming_soon;default:false"`
|
||||
Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"`
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"column:name" binding:"required"`
|
||||
Category string `json:"category" gorm:"column:category" binding:"required"`
|
||||
Description string `json:"description" gorm:"column:description"`
|
||||
Stock float64 `json:"stock" gorm:"column:stock"`
|
||||
Unit string `json:"unit" gorm:"column:unit"`
|
||||
Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"`
|
||||
Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (Product) TableName() string { return "products" }
|
||||
@@ -24,21 +23,6 @@ type ProductPrice struct {
|
||||
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
|
||||
Price float64 `json:"price" gorm:"column:price" binding:"required"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
// Pas de tag gorm "default:true" ici : GORM omet de l'INSERT tout champ
|
||||
// dont la valeur Go est la valeur zéro (false) s'il porte un tag
|
||||
// "default", laissant Postgres appliquer sa propre valeur par défaut
|
||||
// (TRUE) à la place — un prix explicitement désactivé (false) revenait
|
||||
// donc toujours actif après un Create(). La colonne a déjà son défaut
|
||||
// TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et
|
||||
// seulement source du bug.
|
||||
ActivePrice bool `json:"active_price" gorm:"column:active_price"`
|
||||
|
||||
// Champs transitoires (non persistés, gorm:"-") : annotés à la volée sur
|
||||
// les endpoints de lecture client si une promotion s'applique à ce palier
|
||||
// précis (voir handlers.applyPromotions) — permet d'afficher le prix
|
||||
// barré + le prix promo sans toucher au prix catalogue réel.
|
||||
PromoPrice *float64 `json:"promo_price,omitempty" gorm:"-"`
|
||||
PromoPercent float64 `json:"promo_percent,omitempty" gorm:"-"`
|
||||
}
|
||||
|
||||
func (ProductPrice) TableName() string { return "product_prices" }
|
||||
|
||||
@@ -19,3 +19,11 @@ type DeliveryPersonStatus struct {
|
||||
CurrentCommand int `json:"current_command,omitempty"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
}
|
||||
|
||||
type StockReservation struct {
|
||||
ProductID int `json:"product_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
Username string `json:"username"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CommandID int `json:"command_id"`
|
||||
}
|
||||
|
||||
@@ -14,111 +14,6 @@ type PointsTier struct {
|
||||
Points int `json:"points"`
|
||||
}
|
||||
|
||||
// RewardProductQuantity associe un produit à sa propre quantité offerte / à
|
||||
// -50%, pour le cas où une catégorie n'est pas configurée en "tous les
|
||||
// produits" — ex: produit A à 2g offerts, produit B à 1g offert, tous deux
|
||||
// dans la même catégorie et le même type de récompense.
|
||||
type RewardProductQuantity struct {
|
||||
ProductID int `json:"product_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense,
|
||||
// le type de récompense appliqué pour cette catégorie précise, et la quantité
|
||||
// concernée (ex: 1g offert, ou 2g à -50%) — la quantité correspond au palier
|
||||
// de prix catalogue du produit (voir GetActiveProductPrice), pas une valeur
|
||||
// libre : ex. "30€ offert = 1g" si le produit a un palier quantity=1 à 30€.
|
||||
//
|
||||
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
|
||||
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
||||
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
|
||||
type RewardCategoryConfig struct {
|
||||
Category string `json:"category"` // nom de la catégorie
|
||||
Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
|
||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
|
||||
Products []RewardProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
||||
}
|
||||
|
||||
// RewardItem représente un produit résolu à ajouter au panier lors d'un
|
||||
// claim (ProductID + Quantity + Price effectif) — construit dynamiquement à
|
||||
// partir des CategoryConfigs au moment du claim, plus une liste saisie à part.
|
||||
type RewardItem struct {
|
||||
ProductID int `json:"product_id"` // ID du produit ajouté au panier
|
||||
Quantity float64 `json:"quantity"` // quantité offerte
|
||||
Price float64 `json:"price"` // prix effectif facturé (0 si offert, 50% du prix catalogue si -50%)
|
||||
}
|
||||
|
||||
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés.
|
||||
// Le type de récompense (gratuit ou -50%) et la quantité concernée sont
|
||||
// définis par catégorie dans CategoryConfigs (voir RewardCategoryConfig) —
|
||||
// les produits éligibles et leur quantité ne sont plus saisis à part.
|
||||
type PointsReward struct {
|
||||
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
|
||||
Description string `json:"description"` // description libre affichée au client
|
||||
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie
|
||||
}
|
||||
|
||||
// PromotionProductQuantity associe un produit à sa propre quantité en promo,
|
||||
// pour le cas où une catégorie n'est pas configurée en "tous les produits" —
|
||||
// même logique que RewardProductQuantity mais pour les promotions.
|
||||
type PromotionProductQuantity struct {
|
||||
ProductID int `json:"product_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// CategoryPromotionConfig définit une promotion (réduction en %) appliquée
|
||||
// automatiquement au prix catalogue d'un produit pour une quantité donnée —
|
||||
// contrairement à RewardCategoryConfig, ça ne dépend d'aucun seuil de points :
|
||||
// le prix réduit s'applique à tout client qui commande ce produit à cette
|
||||
// quantité, affiché directement sur le produit. La quantité correspond au
|
||||
// palier de prix catalogue existant (voir GetActiveProductPrice), pas une
|
||||
// valeur libre.
|
||||
//
|
||||
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
|
||||
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
||||
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
|
||||
type CategoryPromotionConfig struct {
|
||||
Category string `json:"category"` // nom de la catégorie
|
||||
DiscountPercent float64 `json:"discount_percent"` // pourcentage de réduction libre (ex: 10, 20, 33.5)
|
||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
|
||||
Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
||||
}
|
||||
|
||||
// FreeGiftTier définit un seuil d'achat et la quantité offerte associée, du
|
||||
// même produit — plusieurs seuils peuvent coexister pour un même produit
|
||||
// (ex: 10g achetés → 1g offert, 20g achetés → 3g offerts) ; le seuil le plus
|
||||
// élevé atteint par la quantité commandée est retenu (voir ResolveFreeGift).
|
||||
type FreeGiftTier struct {
|
||||
BuyQuantity float64 `json:"buy_quantity"` // quantité à acheter pour déclencher l'offre
|
||||
FreeQuantity float64 `json:"free_quantity"` // quantité offerte du même produit
|
||||
}
|
||||
|
||||
// FreeGiftProductQuantity associe un produit à ses propres seuils
|
||||
// d'achat/offre, pour le cas où une catégorie n'est pas configurée en "tous
|
||||
// les produits" — même logique que PromotionProductQuantity mais pour les
|
||||
// offres quantité achetée/offerte.
|
||||
type FreeGiftProductQuantity struct {
|
||||
ProductID int `json:"product_id"`
|
||||
Tiers []FreeGiftTier `json:"tiers"`
|
||||
}
|
||||
|
||||
// CategoryFreeGiftConfig définit une offre "achetez X, Y offert" (du même
|
||||
// produit) appliquée automatiquement dès que la quantité ajoutée au panier
|
||||
// atteint un seuil configuré — indépendant des points de fidélité et des
|
||||
// promotions (cumulable avec elles).
|
||||
//
|
||||
// Si AllProducts = true, Tiers s'applique uniformément à tous les produits de
|
||||
// la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
||||
// Products a ses propres seuils (Tiers au niveau catégorie est alors ignoré).
|
||||
type CategoryFreeGiftConfig struct {
|
||||
Category string `json:"category"` // nom de la catégorie
|
||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||
Tiers []FreeGiftTier `json:"tiers"` // seuils uniformes si AllProducts = true
|
||||
Products []FreeGiftProductQuantity `json:"products"` // produits + seuils individuels si AllProducts = false
|
||||
}
|
||||
|
||||
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
||||
type DaySchedule struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -166,45 +61,24 @@ type DeliveryModeConfig struct {
|
||||
|
||||
// AppSettings contient les paramètres globaux de l'application
|
||||
type AppSettings struct {
|
||||
PenaltiesEnabled bool `json:"penalties_enabled"`
|
||||
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
|
||||
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
|
||||
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
||||
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
||||
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
||||
PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
|
||||
Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie
|
||||
FreeGiftsEnabled bool `json:"free_gifts_enabled"` // activer/désactiver les offres "achetez X, Y offert"
|
||||
FreeGifts []CategoryFreeGiftConfig `json:"free_gifts"` // offres quantité achetée/offerte par catégorie
|
||||
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
|
||||
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
|
||||
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
|
||||
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
||||
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
||||
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
|
||||
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
|
||||
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
||||
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
||||
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
|
||||
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
|
||||
// Palette de couleurs — espace admin
|
||||
AdminColorPrimary string `json:"admin_color_primary"`
|
||||
AdminColorSecondary string `json:"admin_color_secondary"`
|
||||
AdminColorSuccess string `json:"admin_color_success"`
|
||||
AdminColorDanger string `json:"admin_color_danger"`
|
||||
AdminColorWarning string `json:"admin_color_warning"`
|
||||
// Palette de couleurs — app client + site web
|
||||
ClientColorPrimary string `json:"client_color_primary"`
|
||||
ClientColorSecondary string `json:"client_color_secondary"`
|
||||
ClientColorSuccess string `json:"client_color_success"`
|
||||
ClientColorDanger string `json:"client_color_danger"`
|
||||
ClientColorWarning string `json:"client_color_warning"`
|
||||
// Dégradé du titre boutique sur le site web client
|
||||
ClientTitleGradientFrom string `json:"client_title_gradient_from"`
|
||||
ClientTitleGradientTo string `json:"client_title_gradient_to"`
|
||||
PenaltiesEnabled bool `json:"penalties_enabled"`
|
||||
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
|
||||
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
|
||||
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
||||
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
||||
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
|
||||
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
|
||||
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
|
||||
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
||||
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
||||
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
|
||||
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather)
|
||||
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
||||
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
||||
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type WeekdayRow struct {
|
||||
DOW int `gorm:"column:dow"`
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
|
||||
type DayRow struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
|
||||
type ProductRow struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Name string `gorm:"column:name"`
|
||||
Quantity float64 `gorm:"column:total_quantity"`
|
||||
OrderCount int `gorm:"column:order_count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
Category string `gorm:"column:category"`
|
||||
CategoryColor string `gorm:"column:category_color"`
|
||||
}
|
||||
|
||||
type HourRow struct {
|
||||
Hour int `gorm:"column:hour"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
|
||||
type QuantityBreakdownRow struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
ProductName string `gorm:"column:product_name"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
OrderCount int `gorm:"column:order_count"`
|
||||
TotalSold float64 `gorm:"column:total_sold"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
CategoryColor string `gorm:"column:category_color"`
|
||||
}
|
||||
|
||||
type DayRevenueRow struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
|
||||
type DailyProductRow struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
ProductName string `gorm:"column:product_name"`
|
||||
Category string `gorm:"column:category"`
|
||||
CategoryColor string `gorm:"column:category_color"`
|
||||
TotalQuantity float64 `gorm:"column:total_quantity"`
|
||||
OrderCount int `gorm:"column:order_count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
|
||||
type DayRowWithResult struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
type WeekRow struct {
|
||||
WeekNum int `gorm:"column:week_num"`
|
||||
Year int `gorm:"column:year"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
type MonthRow struct {
|
||||
MonthNum int `gorm:"column:month_num"`
|
||||
Year int `gorm:"column:year"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
type TodayRow struct {
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
// ============================================
|
||||
// routes/routes.go - VERSION CORRIGÉE COMPLÈTE
|
||||
// ============================================
|
||||
|
||||
package routes
|
||||
|
||||
import (
|
||||
@@ -9,7 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService, s3Service *services.S3Service) {
|
||||
func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService) {
|
||||
|
||||
// ============================================
|
||||
// 🔐 MIDDLEWARE GLOBAL
|
||||
@@ -17,7 +21,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("database", database)
|
||||
c.Set("geoService", geoService)
|
||||
c.Set("s3Service", s3Service)
|
||||
})
|
||||
|
||||
// ============================================
|
||||
@@ -80,7 +83,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// Approbation livraison
|
||||
cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery)
|
||||
cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal)
|
||||
cartGroupV1.PUT("/commands/:id/address", handlers.UpdateOwnCommandAddress)
|
||||
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
|
||||
cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems)
|
||||
cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory)
|
||||
@@ -90,10 +92,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
|
||||
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
|
||||
cartGroupV1.GET("/my-commands/history", handlers.GetClientCommandsHistory)
|
||||
// NOTATION LIVREUR
|
||||
cartGroupV1.POST("/orders/:id/rate", handlers.SubmitLivreurRating)
|
||||
cartGroupV1.GET("/orders/:id/rating", handlers.GetOrderRatingStatus)
|
||||
|
||||
// ⭐⭐ PÉNALITÉS CLIENT
|
||||
cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités
|
||||
|
||||
@@ -118,10 +116,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance)
|
||||
cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo)
|
||||
|
||||
// 🏆 POINTS & RÉCOMPENSES CLIENT
|
||||
cartGroupV1.GET("/points/rewards", handlers.GetMyPointsRewards)
|
||||
cartGroupV1.POST("/points/claim", handlers.ClaimMyReward)
|
||||
|
||||
// 💸 STATUT PAIEMENT CRYPTO
|
||||
cartGroupV1.GET("/commands/:id/payment-status", handlers.GetCommandPaymentStatus)
|
||||
}
|
||||
@@ -131,26 +125,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// ============================================
|
||||
router.POST("/api/v1/webhooks/nowpayments", handlers.IPNWebhook)
|
||||
|
||||
// ============================================
|
||||
// 🖼️ PROXY MÉDIAS (RustFS privé via VPN)
|
||||
// ============================================
|
||||
router.GET("/media/*key", handlers.ServeMedia)
|
||||
|
||||
// ============================================
|
||||
// 🤖 WEBHOOK TELEGRAM - PUBLIC (sécurisé par secret header)
|
||||
// ============================================
|
||||
router.POST("/webhook/telegram", handlers.TelegramWebhook)
|
||||
|
||||
// ============================================
|
||||
// 🔗 LIAISON INTERNE TELEGRAM (appelée par LBTelegram)
|
||||
// ============================================
|
||||
router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink)
|
||||
|
||||
// ============================================
|
||||
// 📋 HEALTH CHECK
|
||||
// ============================================
|
||||
router.GET("/health", handlers.Health)
|
||||
|
||||
// ============================================
|
||||
// 📋 PATTERN v2: ADMIN API
|
||||
// ============================================
|
||||
@@ -160,6 +139,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// ============================================
|
||||
adminAuthGroupV2 := router.Group("/api/v2/admin/auth")
|
||||
{
|
||||
//adminAuthGroupV2.POST("/register", handlers.RegisterAdmin)
|
||||
adminAuthGroupV2.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginAdmin)
|
||||
adminAuthGroupV2.POST("/logout", handlers.LogoutAdmin)
|
||||
}
|
||||
@@ -208,24 +188,13 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
adminGroupV2.DELETE("/products/:id", handlers.DeleteProduct)
|
||||
adminGroupV2.POST("/products/:id/media", handlers.UploadMedia)
|
||||
adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia)
|
||||
adminGroupV2.POST("/products/:id/stock", handlers.UpdateStock)
|
||||
// ============================================
|
||||
// CATÉGORIES - GESTION ADMIN
|
||||
// ============================================
|
||||
adminGroupV2.POST("/categories", handlers.CreateCategory)
|
||||
adminGroupV2.PUT("/categories/reorder", handlers.ReorderCategories)
|
||||
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
|
||||
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
|
||||
// ============================================
|
||||
// STATISTIQUES ADMIN
|
||||
// ============================================
|
||||
adminGroupV2.GET("/stats", handlers.GetAdminStats)
|
||||
adminGroupV2.POST("/stats/reset/:section", handlers.ResetAdminStats)
|
||||
adminGroupV2.GET("/stats/monthly", handlers.GetAdminStatsByMonth)
|
||||
adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice)
|
||||
adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice)
|
||||
adminGroupV2.GET("/stats/daily", handlers.GetAdminDailyDetail)
|
||||
// ============================================
|
||||
// COMMANDES - GESTION DE BASE
|
||||
// ============================================
|
||||
adminGroupV2.GET("/orders", handlers.GetAllCommands)
|
||||
@@ -278,8 +247,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
adminGroupV2.PUT("/delivery-persons/update/:username/location", handlers.UpdateDeliveryPersonLocationAdmin)
|
||||
adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue)
|
||||
adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks)
|
||||
adminGroupV2.GET("/delivery-persons/:username/ratings", handlers.GetLivreurRatings)
|
||||
adminGroupV2.GET("/delivery-persons/:username/login-history", handlers.GetLivreurLoginHistory)
|
||||
// Commandes annulées
|
||||
adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders)
|
||||
// ============================================
|
||||
@@ -291,7 +258,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0
|
||||
adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool
|
||||
adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool
|
||||
adminGroupV2.POST("/client/:username/rewards/reset", handlers.AdminResetClientRedeemed) // Reset récompenses réclamées
|
||||
adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
|
||||
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
|
||||
|
||||
@@ -339,7 +305,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
cabineGroupV1.POST("/telegram/link-token", handlers.GenerateAdminLinkToken)
|
||||
cabineGroupV1.DELETE("/telegram/unlink", handlers.UnlinkAdminTelegram)
|
||||
|
||||
cabineGroupV1.GET("/commands", handlers.GetAllCommands)
|
||||
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
|
||||
cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery)
|
||||
cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson)
|
||||
@@ -348,10 +313,9 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus)
|
||||
cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
|
||||
cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen)
|
||||
cabineGroupV1.GET("/delivery-persons/:username", handlers.GetDeliveryPersonDetails)
|
||||
cabineGroupV1.GET("/all/clients", handlers.GetAllClients)
|
||||
cabineGroupV1.DELETE("/commands/:id", handlers.DeleteCommandByCabine)
|
||||
cabineGroupV1.POST("/commands/:id/propose-address", handlers.ProposeAddressChange)
|
||||
// ⭐ NOUVEAU - ANNULATION PAR CABINE
|
||||
cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders)
|
||||
cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
|
||||
cabineGroupV1.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités
|
||||
@@ -379,8 +343,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
livreurGroupV1.GET("/deliveries", handlers.GetMyDeliveries) // ✅ Données filtrées
|
||||
livreurGroupV1.GET("/deliveries/:id", handlers.GetDeliveryDetails) // ✅ Détail filtré
|
||||
livreurGroupV1.POST("/deliveries/:id/start", handlers.StartDelivery)
|
||||
livreurGroupV1.PUT("/deliveries/:id/status", handlers.UpdateDeliveryStatus) // ✅ Avec GPS
|
||||
livreurGroupV1.POST("/deliveries/:id/issue", handlers.ReportDeliveryIssue) // Motif non-livraison
|
||||
livreurGroupV1.PUT("/deliveries/:id/status", handlers.UpdateDeliveryStatus) // ✅ Avec GPS
|
||||
livreurGroupV1.POST("/deliveries/:id/issue", handlers.ReportDeliveryIssue) // Motif non-livraison
|
||||
livreurGroupV1.GET("/deliveries/:id/nav-link", handlers.GetLivreurNavLink) // Lien Waze App
|
||||
|
||||
// ============================================
|
||||
@@ -399,7 +363,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// QUEUE PERSONNELLE
|
||||
// ============================================
|
||||
livreurGroupV1.GET("/queue", handlers.GetMyQueue)
|
||||
livreurGroupV1.GET("/stats", handlers.GetMyDeliveryStats)
|
||||
|
||||
// ============================================
|
||||
// ALERTES POLICE
|
||||
@@ -412,8 +375,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// ============================================
|
||||
// NOTIFICATIONS LIVREUR
|
||||
// ============================================
|
||||
livreurGroupV1.GET("/ratings", handlers.GetMyRatings)
|
||||
|
||||
livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications)
|
||||
livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
|
||||
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/utils"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
// AddressSuggestion représente une suggestion de correction
|
||||
type AddressSuggestion struct {
|
||||
OriginalAddress string `json:"original_address"`
|
||||
CorrectedAddress string `json:"corrected_address"`
|
||||
Coordinates Coordinates `json:"coordinates"`
|
||||
Confidence float64 `json:"confidence"` // 0.0 à 1.0
|
||||
CorrectionApplied bool `json:"correction_applied"` // true si une correction a été faite
|
||||
Source string `json:"source"` // "exact", "fuzzy", "structured"
|
||||
}
|
||||
|
||||
// NominatimSuggestion représente une réponse de l'API Nominatim
|
||||
type NominatimSuggestion struct {
|
||||
Latitude float64 `json:"lat,string"`
|
||||
Longitude float64 `json:"lon,string"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Importance float64 `json:"importance"`
|
||||
Type string `json:"type"`
|
||||
Class string `json:"class"`
|
||||
Address struct {
|
||||
HouseNumber string `json:"house_number"`
|
||||
Road string `json:"road"`
|
||||
City string `json:"city"`
|
||||
Town string `json:"town"`
|
||||
Village string `json:"village"`
|
||||
Postcode string `json:"postcode"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"country_code"`
|
||||
} `json:"address"`
|
||||
}
|
||||
|
||||
// AddressCorrectionService gère la correction des adresses
|
||||
type AddressCorrectionService struct {
|
||||
httpClient *http.Client
|
||||
geoService *GeoService
|
||||
}
|
||||
|
||||
// NewAddressCorrectionService crée une instance du service de correction
|
||||
func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionService {
|
||||
return &AddressCorrectionService{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
geoService: geoService,
|
||||
}
|
||||
}
|
||||
|
||||
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
|
||||
rawAddress = strings.TrimSpace(rawAddress)
|
||||
if rawAddress == "" {
|
||||
return nil, fmt.Errorf("adresse vide")
|
||||
}
|
||||
|
||||
if loc, err := acs.geoService.getFromCache(rawAddress); err == nil {
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: rawAddress,
|
||||
CorrectedAddress: rawAddress,
|
||||
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
|
||||
Confidence: 1.0,
|
||||
CorrectionApplied: false,
|
||||
Source: "exact",
|
||||
}, nil
|
||||
}
|
||||
if loc, err := acs.geoService.fetchFromNominatim(rawAddress); err == nil {
|
||||
acs.geoService.saveToCache(rawAddress, loc)
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: rawAddress,
|
||||
CorrectedAddress: rawAddress,
|
||||
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
|
||||
Confidence: 1.0,
|
||||
CorrectionApplied: false,
|
||||
Source: "exact",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Étape 2 : fuzzy search Nominatim ──
|
||||
if suggestion, err := acs.nominatimFuzzySearch(rawAddress); err == nil {
|
||||
return suggestion, nil
|
||||
}
|
||||
|
||||
// ── Étape 3 : décomposition structurée ──
|
||||
if suggestion, err := acs.structuredSearch(rawAddress); err == nil {
|
||||
return suggestion, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
|
||||
}
|
||||
|
||||
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
|
||||
variants := buildAddressVariants(address)
|
||||
|
||||
for _, variant := range variants {
|
||||
suggestions, err := acs.queryNominatim(variant, 5)
|
||||
if err != nil || len(suggestions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
best := suggestions[0]
|
||||
confidence := computeConfidence(address, best.DisplayName, best.Importance)
|
||||
|
||||
// On accepte si la confiance est suffisante
|
||||
if confidence >= 0.40 {
|
||||
corrected := formatNominatimAddress(best)
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: address,
|
||||
CorrectedAddress: corrected,
|
||||
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
|
||||
Confidence: confidence,
|
||||
CorrectionApplied: !strings.EqualFold(utils.NormalizeAddress(address), utils.NormalizeAddress(corrected)),
|
||||
Source: "fuzzy",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
|
||||
}
|
||||
|
||||
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("requête vide")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("format", "json")
|
||||
params.Set("addressdetails", "1")
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
params.Set("accept-language", "fr")
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", NominatimBaseURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "DeliveryApp/1.0 (address-correction)")
|
||||
|
||||
// Respect du rate-limit Nominatim : 1 req/s
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
resp, err := acs.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("nominatim status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []NominatimSuggestion
|
||||
if err := json.Unmarshal(body, &results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 3 : RECHERCHE STRUCTURÉE
|
||||
// ============================================
|
||||
|
||||
// structuredSearch décompose l'adresse et cherche les parties clés
|
||||
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
|
||||
parts := parseAddressParts(address)
|
||||
|
||||
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
|
||||
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
|
||||
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
||||
s.OriginalAddress = address
|
||||
s.Source = "structured"
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
if parts.streetName != "" && parts.postcode != "" {
|
||||
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
|
||||
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
||||
s.OriginalAddress = address
|
||||
s.Source = "structured"
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
if parts.city != "" && parts.postcode != "" {
|
||||
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
|
||||
suggestions, err := acs.queryNominatim(q, 3)
|
||||
if err == nil && len(suggestions) > 0 {
|
||||
best := suggestions[0]
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: address,
|
||||
CorrectedAddress: best.DisplayName,
|
||||
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
|
||||
Confidence: 0.30, // faible : seulement ville/CP trouvés
|
||||
CorrectionApplied: true,
|
||||
Source: "structured_partial",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("recherche structurée échouée")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VARIANTES D'ADRESSE
|
||||
// ============================================
|
||||
|
||||
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
|
||||
func buildAddressVariants(address string) []string {
|
||||
variants := []string{address}
|
||||
normalized := utils.NormalizeAddress(address)
|
||||
|
||||
// Variante sans accents
|
||||
if normalized != address {
|
||||
variants = append(variants, normalized)
|
||||
}
|
||||
|
||||
// Variante avec "France" si absent
|
||||
if !strings.Contains(strings.ToLower(address), "france") {
|
||||
variants = append(variants, address+", France")
|
||||
}
|
||||
|
||||
// Variante en corrigeant les abréviations courantes françaises
|
||||
expanded := expandFrenchAbbreviations(address)
|
||||
if expanded != address {
|
||||
variants = append(variants, expanded)
|
||||
variants = append(variants, expanded+", France")
|
||||
}
|
||||
|
||||
// Variante en supprimant les mots de liaison potentiellement mal orthographiés
|
||||
simplified := simplifyStreetName(address)
|
||||
if simplified != address {
|
||||
variants = append(variants, simplified)
|
||||
}
|
||||
|
||||
// Dédoublonnage tout en conservant l'ordre
|
||||
seen := map[string]bool{}
|
||||
unique := make([]string, 0, len(variants))
|
||||
for _, v := range variants {
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
unique = append(unique, v)
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
|
||||
// expandFrenchAbbreviations remplace les abréviations courantes
|
||||
func expandFrenchAbbreviations(address string) string {
|
||||
replacements := []struct{ from, to string }{
|
||||
{"Av.", "Avenue"},
|
||||
{"Ave.", "Avenue"},
|
||||
{"Bd.", "Boulevard"},
|
||||
{"Bld.", "Boulevard"},
|
||||
{"Blvd.", "Boulevard"},
|
||||
{"Rte.", "Route"},
|
||||
{"Rte ", "Route "},
|
||||
{"Imp.", "Impasse"},
|
||||
{"Cité", "Cité"},
|
||||
{"Sq.", "Square"},
|
||||
{"Pl.", "Place"},
|
||||
{"Rés.", "Résidence"},
|
||||
}
|
||||
|
||||
result := address
|
||||
for _, r := range replacements {
|
||||
result = strings.ReplaceAll(result, r.from, r.to)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// simplifyStreetName essaie de nettoyer la rue (retire les particules ambiguës)
|
||||
func simplifyStreetName(address string) string {
|
||||
// Ex: "20 Rue Gabriel le Pan de Ligny" → essai sans "le" → "20 Rue Gabriel Pan de Ligny"
|
||||
// Heuristique légère : on ne modifie que si la chaîne est suffisamment longue
|
||||
words := strings.Fields(address)
|
||||
if len(words) < 5 {
|
||||
return address
|
||||
}
|
||||
|
||||
// Retire les articles intégrés dans le nom de rue (heuristique)
|
||||
articles := map[string]bool{"le": true, "la": true, "les": true, "de": true, "du": true, "des": true, "d": true}
|
||||
filtered := make([]string, 0, len(words))
|
||||
for i, w := range words {
|
||||
lower := strings.ToLower(w)
|
||||
// Garder le premier mot (numéro) et les mots non-articles, ou les articles en début de nom de rue
|
||||
if i < 2 || !articles[lower] {
|
||||
filtered = append(filtered, w)
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Join(filtered, " ")
|
||||
if result == address {
|
||||
return address
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITAIRES
|
||||
// ============================================
|
||||
|
||||
// addressParts regroupe les composants décomposés d'une adresse
|
||||
type addressParts struct {
|
||||
streetNumber string
|
||||
streetName string
|
||||
postcode string
|
||||
city string
|
||||
}
|
||||
|
||||
// parseAddressParts analyse une adresse libre pour en extraire les composants
|
||||
func parseAddressParts(address string) addressParts {
|
||||
var parts addressParts
|
||||
|
||||
// Extraction du code postal (5 chiffres consécutifs)
|
||||
words := strings.Fields(address)
|
||||
remaining := make([]string, 0, len(words))
|
||||
|
||||
for _, w := range words {
|
||||
if isPostcode(w) {
|
||||
parts.postcode = w
|
||||
} else {
|
||||
remaining = append(remaining, w)
|
||||
}
|
||||
}
|
||||
|
||||
if len(remaining) == 0 {
|
||||
return parts
|
||||
}
|
||||
|
||||
// Premier mot numérique → numéro de rue
|
||||
if isNumeric(remaining[0]) {
|
||||
parts.streetNumber = remaining[0]
|
||||
remaining = remaining[1:]
|
||||
}
|
||||
|
||||
// Détection de la ville : dernier groupe après le code postal
|
||||
// Heuristique : si le dernier mot est une ville connue ou commence par une maj
|
||||
if len(remaining) > 0 {
|
||||
last := remaining[len(remaining)-1]
|
||||
if len(last) > 2 && last[0] >= 'A' && last[0] <= 'Z' {
|
||||
parts.city = last
|
||||
remaining = remaining[:len(remaining)-1]
|
||||
}
|
||||
}
|
||||
|
||||
parts.streetName = strings.Join(remaining, " ")
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
|
||||
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
|
||||
origNorm := utils.NormalizeAddress(strings.ToLower(original))
|
||||
suggNorm := utils.NormalizeAddress(strings.ToLower(suggested))
|
||||
|
||||
// Score de similarité sur les mots communs
|
||||
origWords := strings.Fields(origNorm)
|
||||
suggWords := strings.Fields(suggNorm)
|
||||
|
||||
commonCount := 0
|
||||
for _, ow := range origWords {
|
||||
if len(ow) < 3 {
|
||||
continue // ignorer les petits mots
|
||||
}
|
||||
for _, sw := range suggWords {
|
||||
if strings.Contains(sw, ow) || strings.Contains(ow, sw) || levenshteinRatio(ow, sw) > 0.75 {
|
||||
commonCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var wordScore float64
|
||||
if len(origWords) > 0 {
|
||||
wordScore = float64(commonCount) / float64(len(origWords))
|
||||
}
|
||||
|
||||
// Combinaison : 70% similarité textuelle + 30% importance Nominatim
|
||||
importance := math.Min(nominatimImportance, 1.0)
|
||||
return wordScore*0.70 + importance*0.30
|
||||
}
|
||||
|
||||
// formatNominatimAddress formate l'adresse complète depuis une suggestion Nominatim
|
||||
func formatNominatimAddress(s NominatimSuggestion) string {
|
||||
addr := s.Address
|
||||
var parts []string
|
||||
|
||||
if addr.HouseNumber != "" && addr.Road != "" {
|
||||
parts = append(parts, addr.HouseNumber+" "+addr.Road)
|
||||
} else if addr.Road != "" {
|
||||
parts = append(parts, addr.Road)
|
||||
}
|
||||
|
||||
city := addr.City
|
||||
if city == "" {
|
||||
city = addr.Town
|
||||
}
|
||||
if city == "" {
|
||||
city = addr.Village
|
||||
}
|
||||
|
||||
if addr.Postcode != "" {
|
||||
parts = append(parts, addr.Postcode)
|
||||
}
|
||||
if city != "" {
|
||||
parts = append(parts, city)
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return s.DisplayName
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// isPostcode retourne true si le mot ressemble à un code postal français
|
||||
func isPostcode(s string) bool {
|
||||
if len(s) != 5 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isNumeric retourne true si la chaîne est entièrement numérique
|
||||
func isNumeric(s string) bool {
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
// levenshteinRatio retourne un ratio de similarité entre 0 et 1
|
||||
func levenshteinRatio(a, b string) float64 {
|
||||
d := levenshtein(a, b)
|
||||
maxLen := math.Max(float64(len(a)), float64(len(b)))
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
}
|
||||
return 1.0 - float64(d)/maxLen
|
||||
}
|
||||
|
||||
// levenshtein calcule la distance de Levenshtein entre deux chaînes
|
||||
func levenshtein(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
la, lb := len(ra), len(rb)
|
||||
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
|
||||
dp := make([][]int, la+1)
|
||||
for i := range dp {
|
||||
dp[i] = make([]int, lb+1)
|
||||
dp[i][0] = i
|
||||
}
|
||||
for j := 0; j <= lb; j++ {
|
||||
dp[0][j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= la; i++ {
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
dp[i][j] = min3(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost)
|
||||
}
|
||||
}
|
||||
return dp[la][lb]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +28,10 @@ const (
|
||||
LocationTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// STRUCTURES
|
||||
// ============================================
|
||||
|
||||
type GeoLocation struct {
|
||||
Latitude float64 `json:"lat,string"`
|
||||
Longitude float64 `json:"lon,string"`
|
||||
@@ -47,68 +51,43 @@ type DeliveryDistance struct {
|
||||
}
|
||||
|
||||
type GeoService struct {
|
||||
redis *redis.Client
|
||||
ctx context.Context
|
||||
httpClient *http.Client
|
||||
correctionService *AddressCorrectionService
|
||||
redis *redis.Client
|
||||
ctx context.Context
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONSTRUCTEUR
|
||||
// ============================================
|
||||
|
||||
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
|
||||
gs := &GeoService{
|
||||
return &GeoService{
|
||||
redis: redisClient,
|
||||
ctx: ctx,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
// Le correctionService est initialisé après, car il a besoin de gs lui-même
|
||||
gs.correctionService = NewAddressCorrectionService(gs)
|
||||
return gs
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GÉOCODAGE - API NOMINATIM
|
||||
// ============================================
|
||||
|
||||
func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
|
||||
// 1. Cache Redis (adresse originale)
|
||||
if location, err := gs.getFromCache(address); err == nil {
|
||||
// 1. Vérifier le cache Redis
|
||||
location, err := gs.getFromCache(address)
|
||||
if err == nil {
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// 2. Tentative directe via Nominatim
|
||||
if location, err := gs.fetchFromNominatim(address); err == nil {
|
||||
gs.saveToCache(address, location)
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// 3. ── NOUVEAU : correction automatique de l'adresse ──────────────────
|
||||
// Déclenché uniquement si le géocodage direct a échoué.
|
||||
log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address)
|
||||
|
||||
suggestion, err := gs.correctionService.ResolveAddress(address)
|
||||
location, err = gs.fetchFromNominatim(address)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GEO] Correction impossible pour '%s': %v", address, err)
|
||||
return nil, fmt.Errorf("adresse introuvable : '%s'", address)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if suggestion.CorrectionApplied {
|
||||
log.Printf(
|
||||
"✅ [GEO] Correction appliquée (confiance %.0f%%) : '%s' → '%s'",
|
||||
suggestion.Confidence*100,
|
||||
address,
|
||||
suggestion.CorrectedAddress,
|
||||
)
|
||||
}
|
||||
|
||||
location := &GeoLocation{
|
||||
Latitude: suggestion.Coordinates.Latitude,
|
||||
Longitude: suggestion.Coordinates.Longitude,
|
||||
DisplayName: suggestion.CorrectedAddress,
|
||||
}
|
||||
|
||||
// Mettre en cache avec l'adresse originale pour les prochains appels
|
||||
// 3. Sauvegarder en cache
|
||||
gs.saveToCache(address, location)
|
||||
// Mettre en cache aussi avec l'adresse corrigée
|
||||
if suggestion.CorrectionApplied {
|
||||
gs.saveToCache(suggestion.CorrectedAddress, location)
|
||||
}
|
||||
|
||||
return location, nil
|
||||
}
|
||||
@@ -211,6 +190,10 @@ func (gs *GeoService) getCacheKey(address string) string {
|
||||
return fmt.Sprintf("geocode:cache:%s", address)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CALCULS GÉOGRAPHIQUES
|
||||
// ============================================
|
||||
|
||||
// CalculateDistance calcule la distance entre deux points (formule Haversine)
|
||||
func CalculateDistance(from, to Coordinates) float64 {
|
||||
// Conversion en radians
|
||||
@@ -219,6 +202,7 @@ func CalculateDistance(from, to Coordinates) float64 {
|
||||
lat2Rad := toRadians(to.Latitude)
|
||||
lon2Rad := toRadians(to.Longitude)
|
||||
|
||||
// Différences
|
||||
dLat := lat2Rad - lat1Rad
|
||||
dLon := lon2Rad - lon1Rad
|
||||
|
||||
@@ -234,10 +218,13 @@ func CalculateDistance(from, to Coordinates) float64 {
|
||||
|
||||
// CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback)
|
||||
func CalculateETA(distanceKm float64) int {
|
||||
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
|
||||
if distanceKm < 0.1 {
|
||||
return MinETA
|
||||
return MinETA // Très proche: minimum 3 minutes
|
||||
}
|
||||
|
||||
// Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic)
|
||||
// Plus réaliste que 30 km/h
|
||||
travelTime := (distanceKm / 25.0) * 60.0
|
||||
|
||||
// Ajouter une marge pour le trafic (environ 20%)
|
||||
@@ -254,38 +241,35 @@ func CalculateETA(distanceKm float64) int {
|
||||
return totalMinutes
|
||||
}
|
||||
|
||||
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
|
||||
// Retourne (etaMinutes, distanceKm, error)
|
||||
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
||||
if len(tomTomKeys.keys) == 0 {
|
||||
apiKey := os.Getenv("TOMTOM_API_KEY")
|
||||
if apiKey == "" {
|
||||
// Fallback sur calcul local si pas de clé API
|
||||
distance := CalculateDistance(from, to)
|
||||
return CalculateETA(distance), distance, nil
|
||||
}
|
||||
|
||||
// API TomTom Routing: Calculate Route avec trafic
|
||||
apiURL := fmt.Sprintf(
|
||||
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
|
||||
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
|
||||
)
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
|
||||
buildReq := func(key string) (*http.Request, error) {
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.tomtom.com",
|
||||
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("key", key)
|
||||
q.Set("traffic", "true")
|
||||
q.Set("travelMode", "car")
|
||||
u.RawQuery = q.Encode()
|
||||
return http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
}
|
||||
|
||||
resp, err := tomTomKeys.Do(client, buildReq)
|
||||
resp, err := client.Get(apiURL)
|
||||
if err != nil {
|
||||
// Fallback sur calcul local en cas d'erreur réseau
|
||||
distance := CalculateDistance(from, to)
|
||||
eta := CalculateETA(distance)
|
||||
fmt.Printf("⚠️ TomTom indisponible, fallback: %.2f km -> %d min (%v)\n", distance, eta, err)
|
||||
fmt.Printf("⚠️ TomTom timeout, fallback: %.2f km -> %d min\n", distance, eta)
|
||||
return eta, distance, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Fallback sur calcul local en cas d'erreur API
|
||||
distance := CalculateDistance(from, to)
|
||||
eta := CalculateETA(distance)
|
||||
fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta)
|
||||
@@ -310,14 +294,18 @@ func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
||||
}
|
||||
|
||||
summary := routeResponse.Routes[0].Summary
|
||||
|
||||
// Calculer ETA en minutes (arrondi supérieur)
|
||||
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
|
||||
distanceKm := float64(summary.LengthInMeters) / 1000.0
|
||||
|
||||
// Appliquer minimum
|
||||
if etaMinutes < MinETA {
|
||||
etaMinutes = MinETA
|
||||
}
|
||||
|
||||
fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes)
|
||||
|
||||
return etaMinutes, distanceKm, nil
|
||||
}
|
||||
|
||||
@@ -515,13 +503,13 @@ func (gs *GeoService) GetAllDeliveryDistances(target Coordinates, availableUsern
|
||||
// ============================================
|
||||
|
||||
// GetDeliveryHeatmap retourne toutes les positions des livreurs
|
||||
func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]any, error) {
|
||||
func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
|
||||
keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var heatmap []map[string]any
|
||||
var heatmap []map[string]interface{}
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := gs.redis.Get(gs.ctx, key).Result()
|
||||
@@ -529,7 +517,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]any, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
var location map[string]any
|
||||
var location map[string]interface{}
|
||||
json.Unmarshal([]byte(data), &location)
|
||||
|
||||
username := key[len("delivery:location:"):]
|
||||
@@ -540,7 +528,3 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]any, error) {
|
||||
|
||||
return heatmap, nil
|
||||
}
|
||||
|
||||
func (gs *GeoService) CorrectionService() *AddressCorrectionService {
|
||||
return gs.correctionService
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var LBTelegram *LBTelegramService
|
||||
|
||||
type LBTelegramService struct {
|
||||
gatewayURL string
|
||||
Bot1Username string
|
||||
Bot2Username string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewLBTelegramService() *LBTelegramService {
|
||||
url := os.Getenv("LBTELEGRAM_URL")
|
||||
if url == "" {
|
||||
url = "http://lbtelegram:8081"
|
||||
}
|
||||
svc := &LBTelegramService{
|
||||
gatewayURL: url,
|
||||
Bot1Username: os.Getenv("LBTELEGRAM_BOT1_USERNAME"),
|
||||
Bot2Username: os.Getenv("LBTELEGRAM_BOT2_USERNAME"),
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
LBTelegram = svc
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *LBTelegramService) IsConfigured() bool {
|
||||
return os.Getenv("LBTELEGRAM_URL") != ""
|
||||
}
|
||||
|
||||
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
|
||||
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
|
||||
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
|
||||
payload := map[string]any{
|
||||
"user_id": chatID,
|
||||
"username": username,
|
||||
"role": role,
|
||||
"chat_id": chatID,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := s.client.Post(s.gatewayURL+"/enrollment/begin", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("enrollment: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("enrollment HTTP %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
log.Printf("✅ [LB] Enrollment OK pour %s (%s)", username, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendNotification envoie un message via la gateway LBTelegram.
|
||||
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
|
||||
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
|
||||
payload := map[string]any{
|
||||
"user_id": userID,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := s.client.Post(s.gatewayURL+"/notify", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("notify HTTP %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type S3Service struct {
|
||||
client *s3.Client
|
||||
bucketName string
|
||||
}
|
||||
|
||||
type S3Credentials struct {
|
||||
S3KeyId string
|
||||
S3AccessKey string
|
||||
}
|
||||
|
||||
// NewS3Service initialise le client S3 pointant vers RustFS (accessible via VPN).
|
||||
func NewS3Service(region, bucketName, endpoint string, creds S3Credentials) (*S3Service, error) {
|
||||
var cfg aws.Config
|
||||
var err error
|
||||
|
||||
if creds.S3KeyId != "" && creds.S3AccessKey != "" {
|
||||
cfg, err = config.LoadDefaultConfig(context.TODO(),
|
||||
config.WithRegion(region),
|
||||
config.WithCredentialsProvider(
|
||||
credentials.NewStaticCredentialsProvider(creds.S3KeyId, creds.S3AccessKey, ""),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur chargement config AWS: %w", err)
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
if endpoint != "" {
|
||||
o.BaseEndpoint = aws.String(endpoint) // ex: http://10.x.x.x:9000 (IP interne VPN de RustFS)
|
||||
o.UsePathStyle = true
|
||||
}
|
||||
})
|
||||
|
||||
return &S3Service{
|
||||
client: client,
|
||||
bucketName: bucketName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadFile upload un fichier et renvoie sa clé S3 (pas d'URL publique, RustFS est privé).
|
||||
func (s *S3Service) UploadFile(fileHeader *multipart.FileHeader, folder string) (key string, err error) {
|
||||
ext := filepath.Ext(fileHeader.Filename)
|
||||
fileName := fmt.Sprintf("%s%s", uuid.New().String(), ext)
|
||||
return s.UploadFileWithName(fileHeader, folder, fileName)
|
||||
}
|
||||
|
||||
// UploadFileWithName upload un fichier avec un nom déjà déterminé et renvoie la clé S3.
|
||||
func (s *S3Service) UploadFileWithName(fileHeader *multipart.FileHeader, folder, fileName string) (key string, err error) {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("erreur ouverture fichier: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if _, err := buf.ReadFrom(file); err != nil {
|
||||
return "", fmt.Errorf("erreur lecture fichier: %w", err)
|
||||
}
|
||||
|
||||
key = fmt.Sprintf("%s/%s", folder, fileName)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(buf.Bytes()),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("erreur upload RustFS: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// GetFile récupère un objet depuis RustFS (stream + content-type) pour le proxy.
|
||||
// Le contexte doit rester actif pendant toute la lecture du body par l'appelant.
|
||||
func (s *S3Service) GetFile(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||||
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("erreur lecture RustFS: %w", err)
|
||||
}
|
||||
|
||||
contentType := "application/octet-stream"
|
||||
if out.ContentType != nil {
|
||||
contentType = *out.ContentType
|
||||
}
|
||||
return out.Body, contentType, nil
|
||||
}
|
||||
|
||||
// DeleteFile supprime un fichier à partir de sa clé S3.
|
||||
func (s *S3Service) DeleteFile(key string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur suppression RustFS: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/utils"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Storage abstrait l'emplacement de stockage des médias produits (local ou S3),
|
||||
// pour que tous les points d'upload/suppression respectent le même driver.
|
||||
type Storage interface {
|
||||
// Upload sauvegarde le fichier et renvoie l'URL à persister en DB (models.Media.URL)
|
||||
// et la clé interne (vide pour local, clé S3 sinon — models.Media.Key).
|
||||
Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error)
|
||||
// Delete supprime le fichier. url et key sont ceux stockés en DB pour ce média :
|
||||
// chaque implémentation ignore celui qui ne la concerne pas.
|
||||
Delete(url string, key string) error
|
||||
}
|
||||
|
||||
// LocalStorage stocke les fichiers sur le disque local, sous baseDir (ex: "uploads").
|
||||
type LocalStorage struct {
|
||||
baseDir string
|
||||
}
|
||||
|
||||
func NewLocalStorage(baseDir string) *LocalStorage {
|
||||
return &LocalStorage{baseDir: baseDir}
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
|
||||
destFolder := filepath.Join(s.baseDir, folder)
|
||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
||||
return "", "", fmt.Errorf("erreur création dossier: %w", err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(destFolder, fileName)
|
||||
safeFilePath, err := utils.SanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("chemin invalide: %w", err)
|
||||
}
|
||||
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("erreur ouverture fichier: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.OpenFile(safeFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("erreur création fichier: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
os.Remove(safeFilePath)
|
||||
return "", "", fmt.Errorf("erreur écriture fichier: %w", err)
|
||||
}
|
||||
|
||||
return "/" + filepath.ToSlash(safeFilePath), "", nil
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Delete(url string, key string) error {
|
||||
filePath := ""
|
||||
if len(url) > 0 && url[0] == '/' {
|
||||
filePath = url[1:]
|
||||
} else {
|
||||
filePath = url
|
||||
}
|
||||
|
||||
safeFilePath, err := utils.SanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("chemin invalide: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("erreur suppression: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// S3Storage adapte le S3Service existant (RustFS) à l'interface Storage.
|
||||
type S3Storage struct {
|
||||
s3 *S3Service
|
||||
}
|
||||
|
||||
func NewS3Storage(s3 *S3Service) *S3Storage {
|
||||
return &S3Storage{s3: s3}
|
||||
}
|
||||
|
||||
func (s *S3Storage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
|
||||
key, err = s.s3.UploadFileWithName(fileHeader, folder, fileName)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return "/media/" + key, key, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Delete(url string, key string) error {
|
||||
if key == "" {
|
||||
return fmt.Errorf("clé S3 manquante pour suppression")
|
||||
}
|
||||
return s.s3.DeleteFile(key)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelegramBot est l'instance globale accessible depuis le package db
|
||||
var TelegramBot *TelegramService
|
||||
|
||||
type TelegramService struct {
|
||||
@@ -64,7 +65,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
|
||||
return fmt.Errorf("telegram non configuré")
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
@@ -95,60 +96,14 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMessageWithButtons envoie un message HTML avec des boutons inline (URL buttons).
|
||||
// buttons est une liste de paires [texte, url].
|
||||
func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, buttons [][2]string) error {
|
||||
if !t.IsConfigured() {
|
||||
return fmt.Errorf("telegram non configuré")
|
||||
}
|
||||
|
||||
row := make([]map[string]string, 0, len(buttons))
|
||||
for _, b := range buttons {
|
||||
row = append(row, map[string]string{"text": b[0], "url": b[1]})
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": map[string]any{
|
||||
"inline_keyboard": [][]map[string]string{row},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.botToken)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("création requête: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("envoi: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram API status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetWebhook enregistre l'URL webhook auprès de Telegram
|
||||
func (t *TelegramService) SetWebhook(webhookURL string) error {
|
||||
if !t.IsConfigured() {
|
||||
return fmt.Errorf("telegram non configuré")
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"url": webhookURL,
|
||||
payload := map[string]interface{}{
|
||||
"url": webhookURL,
|
||||
"allowed_updates": []string{"message"},
|
||||
}
|
||||
if t.webhookSecret != "" {
|
||||
|
||||
@@ -11,30 +11,25 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
buildReq := func(key string) (*http.Request, error) {
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.tomtom.com",
|
||||
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("key", key)
|
||||
q.Set("traffic", "true")
|
||||
q.Set("travelMode", "car")
|
||||
u.RawQuery = q.Encode()
|
||||
return http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
apiKey := os.Getenv("TOMTOM_API_KEY")
|
||||
if apiKey == "" {
|
||||
return 0, 0, fmt.Errorf("TOMTOM_API_KEY non configurée")
|
||||
}
|
||||
|
||||
resp, err := tomTomKeys.Do(client, buildReq)
|
||||
url := fmt.Sprintf(
|
||||
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
|
||||
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
|
||||
)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("erreur TomTom: %w", err)
|
||||
return 0, 0, fmt.Errorf("erreur requête TomTom: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -58,9 +53,12 @@ func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64
|
||||
}
|
||||
|
||||
summary := routeResponse.Routes[0].Summary
|
||||
|
||||
// Calculer ETA en minutes (arrondi supérieur)
|
||||
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
|
||||
distanceKm = float64(summary.LengthInMeters) / 1000.0
|
||||
|
||||
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
|
||||
|
||||
return etaMinutes, distanceKm, nil
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type tomTomKeyManager struct {
|
||||
keys []string
|
||||
current atomic.Int32
|
||||
}
|
||||
|
||||
var tomTomKeys = initTomTomKeyManager()
|
||||
|
||||
func initTomTomKeyManager() *tomTomKeyManager {
|
||||
m := &tomTomKeyManager{}
|
||||
seen := map[string]bool{}
|
||||
|
||||
candidates := []string{
|
||||
os.Getenv("TOMTOM_API_KEY"),
|
||||
os.Getenv("TOMTOM_API_KEY_1"),
|
||||
os.Getenv("TOMTOM_API_KEY_2"),
|
||||
os.Getenv("TOMTOM_API_KEY_3"),
|
||||
}
|
||||
for _, k := range candidates {
|
||||
if k != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
m.keys = append(m.keys, k)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔑 [TOMTOM] %d clé(s) API configurée(s)", len(m.keys))
|
||||
return m
|
||||
}
|
||||
|
||||
// currentKey retourne la clé active et son index.
|
||||
func (m *tomTomKeyManager) currentKey() (string, int) {
|
||||
n := len(m.keys)
|
||||
if n == 0 {
|
||||
return "", -1
|
||||
}
|
||||
idx := int(m.current.Load()) % n
|
||||
return m.keys[idx], idx
|
||||
}
|
||||
|
||||
// rotate passe à la clé suivante.
|
||||
func (m *tomTomKeyManager) rotate(fromIdx int) {
|
||||
n := len(m.keys)
|
||||
if n <= 1 {
|
||||
return
|
||||
}
|
||||
next := int32((fromIdx + 1) % n)
|
||||
m.current.CompareAndSwap(int32(fromIdx), next)
|
||||
log.Printf("🔄 [TOMTOM] Rotation clé %d → clé %d (quota atteint)", fromIdx+1, next+1)
|
||||
}
|
||||
|
||||
// Do exécute la requête en rotant automatiquement sur 403/429.
|
||||
// buildReq doit construire une nouvelle *http.Request pour la clé donnée.
|
||||
func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) {
|
||||
n := len(m.keys)
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("aucune clé TomTom configurée (TOMTOM_API_KEY / TOMTOM_API_KEY_1..3)")
|
||||
}
|
||||
|
||||
_, startIdx := m.currentKey()
|
||||
|
||||
for attempt := range n {
|
||||
idx := (startIdx + attempt) % n
|
||||
key := m.keys[idx]
|
||||
req, err := buildReq(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
m.rotate(idx)
|
||||
continue
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("toutes les clés TomTom ont atteint leur quota (%d clé(s) testée(s))", n)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ces tests appellent le vrai service Nominatim (réseau réel, rate-limité à
|
||||
// 1 req/s — voir services/adresses_correction.go). Contrairement aux tests
|
||||
// purs de services/adresses_correction_test.go (normalisation, décomposition,
|
||||
// scoring — sans réseau), ceux-ci vérifient le comportement de bout en bout
|
||||
// de ResolveAddress sur de vraies adresses nantaises mal écrites.
|
||||
//
|
||||
// Chaque cas a été vérifié manuellement au préalable (curl vers l'API
|
||||
// Nominatim) pour confirmer ce que la recherche directe résout déjà seule
|
||||
// (Nominatim tolère nativement la casse, les accents et certaines
|
||||
// abréviations sans point) et ce qui nécessite réellement la logique de
|
||||
// correction (variantes, décomposition structurée, repli ville+code postal).
|
||||
//
|
||||
// Un délai explicite sépare chaque cas, en plus du throttle déjà appliqué à
|
||||
// chaque requête HTTP interne (1.1s dans queryNominatim), par courtoisie
|
||||
// envers le service public.
|
||||
|
||||
const (
|
||||
nantesLatMin, nantesLatMax = 47.15, 47.28
|
||||
nantesLonMin, nantesLonMax = -1.65, -1.45
|
||||
)
|
||||
|
||||
func isWithinNantes(lat, lon float64) bool {
|
||||
return lat >= nantesLatMin && lat <= nantesLatMax && lon >= nantesLonMin && lon <= nantesLonMax
|
||||
}
|
||||
|
||||
func TestResolveAddress_RealNantesAddresses(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||
}
|
||||
|
||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
correction := services.NewAddressCorrectionService(geoService)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
minConfidence float64
|
||||
maxConfidence float64
|
||||
}{
|
||||
{
|
||||
// Nominatim tolère nativement la casse et l'absence d'accent :
|
||||
// résolution directe (étape 1 de ResolveAddress), confiance max.
|
||||
name: "tout minuscule sans accent",
|
||||
input: "12 rue crebillon 44000 nantes",
|
||||
minConfidence: 0.90,
|
||||
maxConfidence: 1.0,
|
||||
},
|
||||
{
|
||||
// Abréviation sans point ("Pl" au lieu de "Place") — également
|
||||
// tolérée nativement par Nominatim, résolution directe.
|
||||
name: "abréviation sans point",
|
||||
input: "3 Pl Royale 44000 Nantes",
|
||||
minConfidence: 0.90,
|
||||
maxConfidence: 1.0,
|
||||
},
|
||||
{
|
||||
// Faute de frappe réaliste sur un nom de rue réel (Gambetta ->
|
||||
// Gambeta) : vérifié que la recherche Nominatim directe ET
|
||||
// toutes les variantes générées par l'algorithme (accents,
|
||||
// abréviations, décomposition structurée essais 1 et 2)
|
||||
// échouent — seul le repli ville+code postal (essai 3,
|
||||
// confiance fixe 0.30) aboutit. Documente une vraie limite :
|
||||
// l'algorithme ne corrige pas les fautes de frappe arbitraires
|
||||
// dans un nom de rue, il retombe sur "quelque part dans la
|
||||
// bonne ville".
|
||||
name: "faute de frappe non corrigible sur le nom de rue",
|
||||
input: "15 Rue Gambeta 44000 Nantes",
|
||||
minConfidence: 0.25,
|
||||
maxConfidence: 0.35,
|
||||
},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if i > 0 {
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
}
|
||||
|
||||
suggestion, err := correction.ResolveAddress(c.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAddress(%q): %v", c.input, err)
|
||||
}
|
||||
|
||||
if !isWithinNantes(suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) {
|
||||
t.Errorf("coordonnées hors de Nantes pour %q: lat=%.4f lon=%.4f",
|
||||
c.input, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
|
||||
}
|
||||
if suggestion.Confidence < c.minConfidence || suggestion.Confidence > c.maxConfidence {
|
||||
t.Errorf("confiance hors intervalle attendu pour %q: got=%.2f want=[%.2f,%.2f]",
|
||||
c.input, suggestion.Confidence, c.minConfidence, c.maxConfidence)
|
||||
}
|
||||
if suggestion.CorrectedAddress == "" {
|
||||
t.Errorf("adresse corrigée vide pour %q", c.input)
|
||||
}
|
||||
|
||||
t.Logf("%q -> %q (confiance=%.2f, source=%s, correction_appliquée=%v, lat=%.4f lon=%.4f)",
|
||||
c.input, suggestion.CorrectedAddress, suggestion.Confidence, suggestion.Source,
|
||||
suggestion.CorrectionApplied, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Une adresse totalement absurde (aucun rapport avec un lieu réel) doit
|
||||
// échouer proprement plutôt que renvoyer une coordonnée aléatoire.
|
||||
func TestResolveAddress_NonsenseAddressFailsCleanly(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||
}
|
||||
|
||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
correction := services.NewAddressCorrectionService(geoService)
|
||||
|
||||
_, err := correction.ResolveAddress("Xyzzyplonk Zorbaxx 00000 Nullepart")
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur pour une adresse sans aucun rapport avec un lieu réel")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user